invent): * - Classic three-band stage: Top ~65vh (search/wallet/book surface), Middle ~25vh (coach/stats), * Bottom ~10vh (unnumbered tabs Wallet / Send / Trade book). White 1px borders, black void. * - Slide-in animations (left/right/up). DNA line: TRADE · money/buy · orange burn. * - Empire entrance (chat DNA): Options · Mirrors · Donate + I AGREE ENTER; wipe facts in modal/coach * NOT a permanent chrome wipe banner. Tabs never numbered 1/2/3. * * 0 PRIME: One file. No signup. Hard to kill. No course. No paywall on basics. * 1 FIRST RUN: Before entrance-modal interaction, create or restore the current * token's wallet and load it without help. This grants no starter NSU and * makes no mint, faucet, modifier, chain, order, transfer, or KING mutation. * Wallet Details always shows the current twelve words in plaintext and warns * that anyone who sees them can spend; write them down and use a personal device. * 2 SEED: The seed is the wallet, only password, and sole spending authority. * One first-party browser token maps to one current plaintext seed row; a * valid seed entry physically replaces that row only. Several devices may * map to the same seed. Cleared storage/private browsing creates a fresh * token and identity; passive/coarse fingerprints have no wallet authority. * The KING can inspect assignment seeds and is technically able to spend * them. No operator spending feature or recovery desk is authorized. Losing * both the seed and a usable device binding loses access to the coins. * 3 NSU: Genesis+treasury = world-population-scaled optimum (not infinite mint). * MASTER FAUCET: genesis supply lives in the treasury wallet (pop × 1000 NSU/human). * That wallet IS the empire faucet for THIS site. Rewards = treasury transfers. * Renters must NOT receive treasury.secret / site wallet seed. * Optional donate path may grant NSU later; never a required purchase. * 4 RESET: Yearly wipe. ONLY user NSU wallet balances survive. Profit/ads/bids/ * orders/modifiers burn. Treasury re-anchors to (world_pop × optimum/human) * so liquidity restarts with the size of the world - predictable flux by * iteration+ephemerality; market has no structural reason to die. Wipe facts * (WARNING NEXT RESET IN ___ DAYS) live in entry modal + coach — no chrome banner. * Early window restarts. * RENT SIPHON (scaffold): at wipe, remaining renter-side profit/ad piles on * THIS crop feed the site treasury/master faucet path — renters never mint. * 5 BOOK: Signed intents; NSU leg on-site; foreign settle off-platform P2P. * Empty book after genesis/wipe → loose bootstrap spread (kind=bootstrap_spread) * around temp anchor 1.0 NOTE/NSU so discovery has a mid; makers tighten. * Market P = book converge; donate buyback arms at P/2 (never fixed sticker). * 6 NO THROAT: No visitor signup, named account, or promised recovery desk. * The plaintext assignment ledger means the server/KING can know and spend * visitor seeds, so Trade must not claim cryptographic non-custody or * seed-only-in-tab storage. That capability is not an operator spend/escrow * product. Site-local renter panel is app-god-mode (incl. replace THIS file), * not OS root. Independent seed-authority wallets remain independent. * 7 SECURITY: Experimental until proven. Honest warnings > false guarantees. * Today: browser_hmac_v1 + ecdsa_p256_v1 (register pubkey once, then signed ops * without seed: transfer, order, cancel, fill, reputation, faucet claim). * Legacy seed-addr wallets keep working; v2 pubkey-hash addr still planned. * 8 MIRRORS: Swarm later; this node is disposable; source must stay auditable. * 9 PANEL: /controlpanel or ?controlpanel=1 — paste THIS site's wallet seed * (same as operator/treasury seed). No recovery desk. No password product path. * Unlock: seed derives treasury_addr (owner). Later rent may use economy.operator_addr. * 10 ECONOMY: site-local ads; donate→NSU via book mid×k then 50% bid / 50% profit. * 11 SHELL: Bottom Wallet / Send / Trade book controls are zero-or-one active. * Clicking the active control returns to zero. Zero shows the useful * baseplate: receipt/send/selected-pair limit book left; selectable scrollable * current/local pair charts right. Exact pair equality governs every quote, * book row, fill, and sparkline; no global midpoint masquerades as pair truth. * Future LLM pair crawling/bridge expansion is deferred and adds no origin now. * * Genesis+treasury NSU · disposable seed wallets · transfers · order book * No downloads · no Monero/Haveno · no TradingView · no CDN · no DB daemon * law amendment 2026-08-12: device assignment honesty + zero-tab baseplate * rev 2026-07-13g - wave7: chain RMW lock transfer/order/admin_pay/extract/lazy_accrue TOCTOU */ /** * FILE MAP (cells/trade.php — physical order; comments only, not a second spec): * charter + PRODUCT CONTRACT ...... law / keep-working / never-become (above) * constants + paths ............... NST_* · $ROOT/$DATA/$CHAIN/$VAULT * identity / auth helpers ......... addr_from_seed · mac · ecdsa · nst_addr_v2 scaffold * brains + wipe copy .............. nst_brain_* · nst_ephemerality_reel_segments * chain / book / economy .......... locks · balances · bootstrap · faucet · ads * E2 conservation ............... nst_conservation_* · freeze file · write gates * panel / vault / admin ........... panel_seed_ok · require_admin · vault notes * routes .......................... ?api=* · ?src/?download · ?controlpanel=1 (early exit) * main HTML/CSS ................... Top/Mid/Bottom DNA · entry gate · unnumbered tabs * client JS IIFE .................. wallet/session · book · ads · coach · gate boot * Edit this cell only; sync-pack writes root/pack 4.php. Do not hand-edit pack. */ declare(strict_types=1); const NST_VERSION = '2026-07-13g'; /** * KING faucet start figure (24_000_000 NSU) is historical scale language only. * Micros = whole_NSU × 1e6. Live mint: NST_GENESIS_TREASURY = 0. * Coins enter via daily emission only. Yearly wipe does not re-anchor or mint. * User balances never seized. Population crawl is metadata only. */ const NST_DECIMALS = 6; const NST_GENESIS_TREASURY = '0'; // empires start at ZERO - coins enter via emission only /* ── MONETARY CONSTITUTION ──────────────────────────────────────────────────── * Every empire carries the SAME cap. No country can inflate its way to power * over a sister; an empire wins by being used, not by printing. That symmetry * is what makes a cross-empire rate mean anything, and it is why this value * must stay a fixed constant - never hardware-relative, never per-host. * Changing anything in this block changes the POLICY fingerprint. That is the * point: a king comparing empires reads the money, not the marketing. * ──────────────────────────────────────────────────────────────────────────── */ const NST_MAX_SUPPLY = '100000000000000'; // 100_000_000 NSU - hard ceiling on all issuance /* Emission (see NSU-DNA/08-BRIDGE-SPEC.md §7). Empires start at zero and coins * spawn in daily while under the cap: * E(today) = (NST_MAX_SUPPLY - minted_to_date) * NST_EMISSION_DAILY_RATE * Self-tapering, never exhausts, no schedule table, auditable from two numbers. * A FIXED DAILY AMOUNT split among claimants - never a payment per claimant. * Paying per head makes issuance = participants x amount, and an attacker picks * the participant count (one IPv6 /64 is 18 quintillion addresses). Splitting a * fixed pool means extra identities dilute only the attacker. */ const NST_EMISSION_DAILY_RATE_PPM = 100; // 100 ppm = 0.01% of remaining, per day /* THE DAY'S EMISSION SPLITS TEN WAYS - ONE SHARE PER CROP. NO POOLS. * * There used to be a 55/35/10 split into a UBI pool, a contribution pool and the * King. Both pools were constant strings rather than seed-derived addresses, so * no key could ever open them, and nothing in the code ever read them: 90% of * every coin minted went somewhere nobody could spend from. It was recoverable * (balances replay from the chain, so a payout added later reaches back) but it * was 90% of issuance parked behind an undesigned distribution rule. * * Operator decision, 2026-08-04: delete the pools. Each of the ten crops gets an * equal tenth of the day's emission, paid straight to that crop's lord wallet, * and the lord decides what to fund with it - advertising is the service every * crop offers from day one, others come later. Protocol-level UBI is gone; a * lord may choose to run one out of their own stream. * * The split is EQUAL and hard-coded at one-tenth on purpose. A percentage table * is a thing somebody has to maintain, argue about and get wrong; ten equal * shares is a thing nobody has to think about again. */ const NST_EMISSION_CROP_COUNT = 10; /* KING'S CUT - ONE RULE, APPLIED TO EVERY LORD RECEIPT. * * The King does not get a carve-out of emission and does not need a conditional * in each fee path. Instead x% comes off the top of ANY revenue landing in a * lord's wallet - their emission share, ad money, and whatever services get * built later. Because a crop's emission share is itself a lord receipt, this * single rule already delivers "a percent of everything issued from the faucet" * without a second mechanism to keep in sync. * * NEVER a user-to-user trade. An order-book fill or a plain transfer landing in * a lord's wallet is NOT revenue - taxing it would skim a lord for buying NSU * with their own money, which is precisely the gatekeeper's cut the ethos * refuses. The tax attaches to revenue row types, never to movement. * * Fees are TAXED, never DUPLICATED. Mirroring a fee into the King's faucet would * be an infinite mint: pay a lord you control, mirror it, repeat at zero cost. * * ON THE RATE. There is no derivable optimum without knowing what the commons * actually costs to run, and inventing precision here would be false. 5% is a * defensible default for one reason: the King already receives a full crop share * as lord of trade, so this tax only has to cover the MARGINAL cost of the * shared parts - the chain, the mint, propagation - above what one crop's share * already pays for. Tune this single constant; nothing else needs to move. */ const NST_KING_TAX_PPM = 50000; // 5% off the top of every lord receipt /** Legacy metadata constant only (old pop-scale docs); liquid target ignores this. */ const NST_OPTIMUM_MICROS_PER_HUMAN = '1000000000000'; /** Fail-open world population if crawl/cache unavailable (UN-scale estimate). */ const NST_POP_FALLBACK = 8200000000; /** Cache TTL for crawled population (seconds). */ const NST_POP_CACHE_SECS = 604800; // 7 days const NST_FAUCET_MICROS = '5000000'; const NST_ORDER_MAX = 200; const NST_DATA = 'data'; const NST_MOD_DAILY_MICROS = '100000'; // 0.1 NSU per day const NST_RESET_SECS = 31536000; // 365 days - yearly superstructure const NST_EARLY_WINDOW_SECS = 2592000; // 30 days after each yearly reset /** P3a rent quote: price = RENT_NSU_PER_DAY * days_left (whole NSU → micros). Payment settle = later residual. */ const NST_RENT_NSU_PER_DAY = 1000; const NST_RENT_QUOTE_TTL_SECS = 1800; // 30 minutes /** Dead address for treasury excess burn on re-anchor (not a user). */ const NST_BURN_ADDR = 'nsu-burn-v1-population-anchor'; /** When book mid missing: gift_nsu = external_amount * K (external in same unit as mid quote). */ const NST_DONATE_K = '1'; const NST_AD_MAX_PNG = 220000; // bytes - site-local weighted board const NST_AD_MAX_W = 2400; const NST_AD_MAX_H = 2400; const NST_AD_MIN_W = 16; const NST_AD_MIN_H = 16; /** Public static donate rails (tentative; vault holds private seeds). */ const NST_DONATE_BTC = 'bc1qqnu6n0jztxl4f6krv7klradghle09uhyu7uymz'; const NST_DONATE_XMR = '8Ab24DppUvcdtHfm7K8gTqdBTmPCBiak1GwxgPm1C3osYVQL2QdC1C8GMwggKF77RKKzDgP2R8E3VH8ifetsKms5AqkVyVg'; const NST_DONATE_LTC = 'ltc1qlpdy8qzejcmjdn6vwarpyz8djdlk780w4qkwyp'; /** Operator / recruit contact (not an account desk). */ const NST_CONTACT_EMAIL = 'buysellfreetrade@proton.me'; /** * LORD rent GRANT path — RETIRED 2026-07-16 (NSU_VIABILITY_V1 CUT #4 / atomic P2). * Rent is PAYMENT TO treasury only (pay-to-treasury + payer-key bind = P3, not this ship). * Constants below are DEAD; nst_lord_rent_credit_compute is dead code; admin_rent_claim returns 410. * Do not re-enable treasury→LORD credit or server-generated LORD seeds. * * KING faucet liquid scale = NST_KING_FAUCET_START_NSU (24_000_000). */ const LORD_RENT_CREDIT_LEGACY = 10000; // DEAD — retired flat placeholder (whole unit) const NST_KING_FAUCET_START_NSU = 24000000; // whole NSU — genesis + re-anchor target /** @deprecated DEAD grant formula — do not use for product rent */ const LORD_RENT_PCT_BPS = 50; /** @deprecated DEAD grant formula */ const LORD_RENT_FLAT_BASE_NSU = 2500; /** @deprecated DEAD grant formula */ const LORD_RENT_FLAT_PERF_BPS = 100; /** @deprecated DEAD grant formula */ const LORD_RENT_FLAT_PERF_CAP_NSU = 50000; /** @deprecated DEAD grant formula */ const LORD_RENT_MIN_NSU = 1000; /** @deprecated DEAD grant formula */ const LORD_RENT_MAX_NSU = 500000; /** @deprecated DEAD — was API field compat for grant credit */ const LORD_RENT_CREDIT = LORD_RENT_CREDIT_LEGACY; /** When paid creatives empty (or luck roll): site-local house discovery ads. */ const NST_HOUSE_AD_CHANCE = 18; // percent chance to show house even if paid pool exists /** * AD SPEND BUYS A SHARE OF THE EPOCH. THERE IS NO METER AND NO REFUND. * * The money is SPENT, not held: it leaves the buyer at purchase and lands with * the lord (the King's cut comes off the top of that receipt). What it buys is a * proportional chance of being the ad shown, across the whole empire, for the * REST OF THE EPOCH. Nothing is ever given back. * * PRICE DECAYS ON ITS OWN, WHICH IS THE POINT. A buy in month one purchases * twelve months of share; the same coins in month eleven purchase one. Nobody * sets that curve and no formula encodes it - late buyers are simply purchasing * less remaining time, so a rational bidder pays less as the wipe approaches. * More competition pushes the same way, since share is your spend over the * board's total. * * WHY NOT A PER-IMPRESSION METER. A build on 2026-08-04 replaced this with a * fixed rate (1 NSU = 1000 impressions) drawn down as ads were served. It looked * more honest and was strictly worse: a meter makes timing irrelevant, because * one NSU buys the same thousand impressions in January or December. That * deletes the decay above - the one signal that makes the board price itself - * and it invents a price the operator would then have to guess and maintain. * It also turned the busiest read in the empire into a locked disk write. * See NSU-DNA/02-REGRESSION-LEDGER.md R-010 (raised, then reversed). * * The ten crops that carry the board. Shown to a buyer so "a share of the * empire" is a checkable claim rather than a slogan. */ const NST_EMPIRE_DOMAINS = 'nosignup.com, nosignup.org, nosignup.net, nosignup.trade, ' . 'nosignup.chat, nosignup.work, nosignup.market, nosignup.date, nosignup.fun, nosignup.info'; /** * Bootstrap discovery book (temp anchor only — market P still converges from live book). * Anchor 1.0 NOTE/NSU = 1_000_000 micros; loose BUY half / SELL 1.5x. * Size each side ≈ treasury / NST_BOOTSTRAP_DENOM (0.1% when denom=1000). */ const NST_BOOTSTRAP_ANCHOR_PX = '1000000'; // 1.0 NOTE per NSU (micros) const NST_BOOTSTRAP_BUY_PX = '500000'; // 0.5 const NST_BOOTSTRAP_SELL_PX = '1500000'; // 1.5 const NST_BOOTSTRAP_DENOM = 1000; // amount = floor(treasury/denom) per side /** * OPERATOR_GO §4 / E2a: KING real fillable SELL ladder (NOT bootstrap_spread). * Three rungs around discovery anchor; small fixed size so book has TAKE-able asks. * Each SELL arms C2 half-BID via nst_treasury_half_bid_from_sell. */ const NST_LADDER_AMT_NSU = '100'; // 100 NSU per rung (micros via parse_amt) const NST_LADDER_AMT_MAX_NSU = '1000'; // hard cap per rung (anti-drain) const NST_LADDER_PX_LO = '1000000'; // 1.0 NOTE/NSU const NST_LADDER_PX_MID = '1250000'; // 1.25 const NST_LADDER_PX_HI = '1500000'; // 1.5 /** * Empire setup console (post-nuke / first host): * While data/empire_setup.json filled≠true, public visitors are redirected here. * Sister crops redirect to this host's ?empire_setup=1 (NST_EMPIRE_SETUP_PUBLIC_URL). * KING seeds treasury ladder + marks filled. AI never holds treasury seed. */ const NST_EMPIRE_SETUP_PUBLIC_URL = 'https://nosignup.trade/?empire_setup=1'; /** E2: throttle full conservation replay (seconds). Freeze file always honored. */ const NST_CONSERVATION_RECHECK_SECS = 300; /** Wallet Inbox v0: short-lived sealed notices (mail≠mint). Aligned with bid ephemerality. */ const NST_INBOX_TTL_SECS = 604800; // 7 days default const NST_INBOX_MAX_CT_HEX = 8192; const NST_INBOX_ALG = 'ecdh_p256_hkdf_sha256_aes256gcm_v1'; /** Device-wallet binding store: bounded, line-oriented, and isolated from money state. */ const NST_DEVICE_WALLET_BODY_MAX = 2048; const NST_DEVICE_WALLET_FILE_MAX = 16777216; const NST_DEVICE_WALLET_ROWS_MAX = 50000; const NST_DEVICE_WALLET_LINE_MAX = 384; const NST_DEVICE_WALLET_SEED_RAW_MAX = 256; const NST_DEVICE_WALLET_SEED_CANON_MAX = 255; const NST_DEVICE_WALLET_PROFILE_FIELDS_MAX = 16; const NST_DEVICE_WALLET_PROFILE_KEY_MAX = 32; const NST_DEVICE_WALLET_PROFILE_VALUE_MAX = 128; const NST_DEVICE_WALLET_COOKIE = 'nst_trade_device_v1'; const NST_DEVICE_WALLET_COOKIE_MAX_AGE = 31536000; // 365d cap; issue uses remaining epoch const NST_PUBLIC_BOOK_SIDE_MAX = 500; const NST_PUBLIC_RECENT_FILL_MAX = 200; $ROOT = __DIR__; $DATA = $ROOT . DIRECTORY_SEPARATOR . NST_DATA; $CHAIN = $DATA . DIRECTORY_SEPARATOR . 'chain.jsonl'; $CHAIN_TXN = $DATA . DIRECTORY_SEPARATOR . 'chain.txn'; $TFILE = $DATA . DIRECTORY_SEPARATOR . 'treasury.secret'; // KING vault seed (only minter) $SITE_SEED_FILE = $DATA . DIRECTORY_SEPARATOR . 'site.seed'; // trade panel wallet seed (not mint) $META = $DATA . DIRECTORY_SEPARATOR . 'meta.json'; $ECON = $DATA . DIRECTORY_SEPARATOR . 'economy.json'; $ADS = $DATA . DIRECTORY_SEPARATOR . 'ads'; $ADMIN_HASH_FILE = $DATA . DIRECTORY_SEPARATOR . 'admin.pass.hash'; $ADMIN_PASS_FILE = $DATA . DIRECTORY_SEPARATOR . 'admin.pass.txt'; // legacy plaintext migrate $MODS_FILE = $DATA . DIRECTORY_SEPARATOR . 'modifiers.json'; $OP_ADDR_FILE = $DATA . DIRECTORY_SEPARATOR . 'operator.addr'; // site panel addr (≠ treasury/KING on fresh genesis) /** P3b: durable open rent quotes (JSON map). Not chain; settle residual reads this. */ $RENT_QUOTES_FILE = $DATA . DIRECTORY_SEPARATOR . 'rent_quotes.json'; /** * Local/CLI execution must not escape the source tree. A normal web deployment keeps * the existing sibling-vault separation; Desktop/NSU and isolated candidate runs do not. */ function nst_source_local_runtime_required(string $root): bool { if (PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server') { return true; } if ((string)getenv('NST_SOURCE_LOCAL_RUNTIME') === '1') { return true; } $paths = [$root]; $real = realpath($root); if (is_string($real) && $real !== '') { $paths[] = $real; } foreach ($paths as $path) { $normalized = str_replace('\\', '/', $path); if (preg_match('~(?:^|/)Desktop/NSU(?:/|$)~i', $normalized)) { return true; } if (preg_match('~(?:^|/)(?:baseline|candidate|candidates)(?:/|$)~i', $normalized)) { return true; } } return false; } $NST_SOURCE_LOCAL_RUNTIME = nst_source_local_runtime_required($ROOT); $VAULT_BASE = $NST_SOURCE_LOCAL_RUNTIME ? $ROOT : dirname($ROOT); $VAULT = $VAULT_BASE . DIRECTORY_SEPARATOR . 'vault'; /** Never let a front controller turn private state paths into a 200 product page. */ function nst_reject_private_http_path(): void { if (PHP_SAPI === 'cli') return; $uri = (string)($_SERVER['REQUEST_URI'] ?? ''); $path = (string)(@parse_url($uri, PHP_URL_PATH) ?? ''); for ($i = 0; $i < 2; $i++) { $decoded = rawurldecode($path); if ($decoded === $path) break; $path = $decoded; } $path = str_replace('\\', '/', $path); if (str_contains($path, "\0") || preg_match('~(?:^|/)(?:vault|data)(?:/|$)~i', $path)) { http_response_code(404); header('Content-Type: text/plain; charset=UTF-8'); header('Cache-Control: private, no-store, max-age=0, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); echo "Not found\n"; exit; } } nst_reject_private_http_path(); /* PHP's development server can otherwise emit an uncaught exception page with * HTTP 200 and an absolute filesystem path. Every unhandled product failure is * a closed 503 with a non-secret body; CLI diagnostics retain a nonzero exit. */ set_exception_handler(static function (Throwable $error): void { if (PHP_SAPI === 'cli') { fwrite(STDERR, "nosignup.trade unavailable\n"); exit(1); } if (!headers_sent()) { http_response_code(503); header('Cache-Control: private, no-store, max-age=0, must-revalidate'); header('Pragma: no-cache'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); } $api = (string)($_GET['api'] ?? $_POST['api'] ?? ''); if ($api !== '') { if (!headers_sent()) header('Content-Type: application/json; charset=UTF-8'); echo json_encode(['ok' => false, 'err' => 'service_unavailable'], JSON_UNESCAPED_SLASHES); } else { if (!headers_sent()) header('Content-Type: text/plain; charset=UTF-8'); echo "Service unavailable\n"; } exit; }); function j($x): string { return json_encode($x, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } /** PHP 8.0-compatible replacement for array_is_list(), added in PHP 8.1. */ function nst_is_list_compat(array $value): bool { if (function_exists('array_is_list')) { return array_is_list($value); } $expected = 0; foreach (array_keys($value) as $key) { if ($key !== $expected) { return false; } $expected++; } return true; } function norm_seed(string $s): string { return strtolower(trim(preg_replace('/\s+/', ' ', $s) ?? '')); } /** Path under durable $DATA — same path strings for L0/L1 money/identity (never RAM). */ function nst_data_file(string $name): string { global $DATA; return $DATA . DIRECTORY_SEPARATOR . $name; } /** * Ephemeral dir for regenerable L3 only (world_pop cache). * Prefer /dev/shm when present+writable; else durable $DATA/ephemeral/. * DENYLIST stays on disk: chain, treasury.secret, site.seed, rent_quotes, economy, * modifiers, operator.addr, admin.pass.hash, conservation.freeze, pubkey SoT. */ function nst_ephemeral_dir(): string { global $DATA, $NST_SOURCE_LOCAL_RUNTIME; static $cached = null; if ($cached !== null) { return $cached; } if (!$NST_SOURCE_LOCAL_RUNTIME && is_dir('/dev/shm') && is_writable('/dev/shm')) { $d = '/dev/shm/nosignup_trade_ephemeral'; if ((is_dir($d) || @mkdir($d, 0700, true)) && is_writable($d)) { return $cached = $d; } } $d = $DATA . DIRECTORY_SEPARATOR . 'ephemeral'; if (!is_dir($d)) { @mkdir($d, 0755, true); } return $cached = $d; } /** First two hex chars of addr (256 fan-out). Fallback crc32%256 if short. */ function nst_addr_shard2(string $addr): string { $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; if (strlen($a) >= 2) { return substr($a, 0, 2); } return sprintf('%02x', abs(crc32($a)) % 256); } /** Legacy addr (v1): hash of seed. Future dual: ECDSA P-256 pubkey-hash addr + this legacy. */ /** * AN EMPTY SEED HAS NO ADDRESS. * * This used to hash the empty string like any other input, producing the fixed * constant sha256("nsu-addr-v1|") = b6a89c23... That address looks completely * ordinary and no 12-word seed can ever derive it, so every coin sent there is * burned with no way back. * * It reached production. When trade could not read data/treasury.secret, * file_get_contents() returned false, trim(false) became '', and treasury_addr() * handed back that constant - so ?api=state reported a healthy-looking treasury * that held nothing and could never be spent. A missing key read as a valid * wallet instead of as an error. * * Returning '' makes the failure visible: callers already test for the empty * address (nst_treasury_addr_safe, the emission guard, the ad_stake fallback), * and an empty address can never be an output leg. */ function addr_from_seed(string $seed): string { $n = norm_seed($seed); if ($n === '') return ''; return hash('sha256', 'nsu-addr-v1|' . $n); } /** HMAC for browser_hmac_v1 (key includes seed → seed still needed to verify). */ function mac_sign(string $seed, string $msg): string { return hash_hmac('sha256', $msg, 'nsu-mac-v1|' . norm_seed($seed)); } /** ECDSA P-256 dual-path: seed-off-wire spend after pubkey registered for legacy addr. */ function nst_pubkey_path(string $addr): string { global $DATA; $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; $aa = nst_addr_shard2($a); $dir = $DATA . DIRECTORY_SEPARATOR . 'keys' . DIRECTORY_SEPARATOR . $aa; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } return $dir . DIRECTORY_SEPARATOR . 'pubkey_' . $a . '.spki'; } /** Pre-footprint flat path (read fallback only; writes go to keys/{aa}/). */ function nst_pubkey_path_legacy(string $addr): string { global $DATA; return $DATA . DIRECTORY_SEPARATOR . 'pubkey_' . preg_replace('/[^a-f0-9]/', '', strtolower($addr)) . '.spki'; } function nst_addr_v2_from_spki_bin(string $spki): string { return hash('sha256', 'nsu-addr-v2|' . bin2hex($spki)); } /** IEEE P1363 (r||s, 64 bytes for P-256) → DER SEQUENCE for openssl_verify. */ function nst_ecdsa_p1363_to_der(string $p1363): ?string { if (strlen($p1363) !== 64) return null; $encInt = function (string $x): string { $x = ltrim($x, "\x00"); if ($x === '') $x = "\x00"; if ((ord($x[0]) & 0x80) !== 0) $x = "\x00" . $x; return "\x02" . chr(strlen($x)) . $x; }; $r = $encInt(substr($p1363, 0, 32)); $s = $encInt(substr($p1363, 32, 32)); $seq = $r . $s; return "\x30" . chr(strlen($seq)) . $seq; } /** True when host can verify ECDSA P-256 (OpenSSL extension). */ function nst_ecdsa_openssl_ready(): bool { return function_exists('openssl_verify') && function_exists('openssl_pkey_get_public'); } function nst_ecdsa_verify_p256(string $spkiBin, string $msg, string $sigP1363Hex): bool { if (!nst_ecdsa_openssl_ready()) return false; $sigRaw = @hex2bin(strtolower(preg_replace('/[^a-f0-9]/', '', $sigP1363Hex) ?? '')); if ($sigRaw === false || strlen($sigRaw) !== 64) return false; $der = nst_ecdsa_p1363_to_der($sigRaw); if ($der === null) return false; $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spkiBin), 64, "\n") . "-----END PUBLIC KEY-----\n"; $pub = @openssl_pkey_get_public($pem); if ($pub === false) return false; $ok = openssl_verify($msg, $der, $pub, OPENSSL_ALGO_SHA256); return $ok === 1; } function nst_load_registered_spki(string $addr): ?string { $p = nst_pubkey_path($addr); if (!is_file($p)) { $p = nst_pubkey_path_legacy($addr); } if (!is_file($p)) return null; $hex = trim((string)@file_get_contents($p)); $bin = @hex2bin(strtolower(preg_replace('/[^a-f0-9]/', '', $hex) ?? '')); return ($bin !== false && strlen($bin) > 40) ? $bin : null; } function nst_save_registered_spki(string $addr, string $spkiBin): bool { if (strlen($spkiBin) < 40) return false; return @file_put_contents(nst_pubkey_path($addr), bin2hex($spkiBin), LOCK_EX) !== false; } /** * WALLET INBOX v0 — address-keyed sealed mail (edit>invent on ECDSA/SPKI DNA). * KEY SEPARATION (documented): * - Signing key (ECDSA P-256): existing register_pubkey path (random key, AES-wrapped * at salt nsu-ecdsa-wrap-v1). Signs spends AND signs sealed inbox blobs. * - Encryption key (ECDH P-256): NEW, seed-derived (PBKDF2 salt nsu-ecdh-enc-v1, * 120000 iters → 32-byte scalar → PKCS8). Registered at encpub_.spki. * Distinct file + salt so sign≠encrypt. Recipient unlocks seed → re-derives priv → decrypts. * TRUE E2E: sender (KING panel client) encrypts; server stores OPAQUE ciphertext only. * Honesty: experimental until isolated e2e proof; never claim proven if plaintext touched server. * Mail ≠ mint: no NSU mint/balance change on seal/list/fetch/decrypt. */ function nst_encpub_path(string $addr): string { global $DATA; $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; $aa = nst_addr_shard2($a); $dir = $DATA . DIRECTORY_SEPARATOR . 'keys' . DIRECTORY_SEPARATOR . $aa; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } return $dir . DIRECTORY_SEPARATOR . 'encpub_' . $a . '.spki'; } /** Pre-footprint flat path (read fallback only). */ function nst_encpub_path_legacy(string $addr): string { global $DATA; return $DATA . DIRECTORY_SEPARATOR . 'encpub_' . preg_replace('/[^a-f0-9]/', '', strtolower($addr)) . '.spki'; } function nst_load_registered_enc_spki(string $addr): ?string { $p = nst_encpub_path($addr); if (!is_file($p)) { $p = nst_encpub_path_legacy($addr); } if (!is_file($p)) return null; $hex = trim((string)@file_get_contents($p)); $bin = @hex2bin(strtolower(preg_replace('/[^a-f0-9]/', '', $hex) ?? '')); return ($bin !== false && strlen($bin) > 40) ? $bin : null; } function nst_save_registered_enc_spki(string $addr, string $spkiBin): bool { if (strlen($spkiBin) < 40) return false; return @file_put_contents(nst_encpub_path($addr), bin2hex($spkiBin), LOCK_EX) !== false; } /** * Wallet inbox blob path — sharded under data/inbox/{aa}/ (256 fan-out). * Filename keeps inbox_{addr}_{id}.blob so list/burn globs stay unambiguous. */ function nst_inbox_blob_path(string $addr, string $id): string { global $DATA; $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; $i = preg_replace('/[^a-f0-9]/', '', strtolower($id)) ?? ''; $aa = nst_addr_shard2($a); $dir = $DATA . DIRECTORY_SEPARATOR . 'inbox' . DIRECTORY_SEPARATOR . $aa; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } return $dir . DIRECTORY_SEPARATOR . 'inbox_' . $a . '_' . $i . '.blob'; } /** Pre-footprint flat blob path (list/burn migration only). */ function nst_inbox_blob_path_legacy(string $addr, string $id): string { global $DATA; $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; $i = preg_replace('/[^a-f0-9]/', '', strtolower($id)) ?? ''; return $DATA . DIRECTORY_SEPARATOR . 'inbox_' . $a . '_' . $i . '.blob'; } function nst_inbox_sign_body(array $b): string { return implode('|', [ 'inbox_seal', '1', (string)($b['to'] ?? ''), (string)($b['from'] ?? ''), (string)($b['id'] ?? ''), (string)($b['ts'] ?? ''), (string)($b['exp'] ?? ''), (string)($b['eph_pub'] ?? ''), (string)($b['iv'] ?? ''), (string)($b['ct'] ?? ''), ]); } /** Collect inbox blob paths for addr: sharded inbox/{aa}/ + legacy flat (migration). */ function nst_inbox_glob_for_addr(string $addr): array { global $DATA; $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; if (strlen($a) !== 64) return []; $files = []; $aa = nst_addr_shard2($a); $shardDir = $DATA . DIRECTORY_SEPARATOR . 'inbox' . DIRECTORY_SEPARATOR . $aa; if (is_dir($shardDir)) { foreach (glob($shardDir . DIRECTORY_SEPARATOR . 'inbox_' . $a . '_*.blob') ?: [] as $f) { $files[] = $f; } } // Legacy flat data/inbox_{addr}_*.blob (pre-footprint) foreach (glob($DATA . DIRECTORY_SEPARATOR . 'inbox_' . $a . '_*.blob') ?: [] as $f) { $files[] = $f; } return array_values(array_unique($files)); } /** List non-expired sealed blobs for addr (metadata only; ct still opaque). */ function nst_inbox_list_for_addr(string $addr): array { $a = preg_replace('/[^a-f0-9]/', '', strtolower($addr)) ?? ''; if (strlen($a) !== 64) return []; $out = []; $now = time(); foreach (nst_inbox_glob_for_addr($a) as $f) { $raw = (string)@file_get_contents($f); $j = json_decode($raw, true); if (!is_array($j)) continue; $exp = (int)($j['exp'] ?? 0); if ($exp > 0 && $exp < $now) { @unlink($f); continue; } $out[] = [ 'id' => (string)($j['id'] ?? ''), 'to' => (string)($j['to'] ?? ''), 'from' => (string)($j['from'] ?? ''), 'ts' => (int)($j['ts'] ?? 0), 'exp' => $exp, 'kind' => (string)($j['kind'] ?? 'king_note'), 'alg' => (string)($j['alg'] ?? ''), 'bytes' => strlen($raw), 'honesty' => (string)($j['honesty'] ?? 'experimental · sealed mail · not proven e2e'), ]; } usort($out, static fn($x, $y) => ($y['ts'] ?? 0) <=> ($x['ts'] ?? 0)); return $out; } /** * Yearly wipe / epoch burn: sealed notices die with bids (balances survive). * CRITICAL: must recurse inbox/{aa}/ shards — missed burn = stale blob regression. * Also burns legacy flat data/inbox_*.blob. */ function nst_inbox_burn_all(): int { global $DATA; $n = 0; // Legacy flat foreach (glob($DATA . DIRECTORY_SEPARATOR . 'inbox_*.blob') ?: [] as $f) { if (@unlink($f)) { $n++; } } // Sharded tree: data/inbox/{aa}/inbox_*.blob $root = $DATA . DIRECTORY_SEPARATOR . 'inbox'; if (is_dir($root)) { foreach (glob($root . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) { foreach (glob($dir . DIRECTORY_SEPARATOR . 'inbox_*.blob') ?: [] as $f) { if (@unlink($f)) { $n++; } } } // Any blobs directly under inbox/ (defensive) foreach (glob($root . DIRECTORY_SEPARATOR . 'inbox_*.blob') ?: [] as $f) { if (@unlink($f)) { $n++; } } } return $n; } /** * INBOX auto-wire (money-adjacent): SERVER-COMPOSED notices of PUBLIC book events. * Honesty floor: this is NOT private E2E — the server sees the (public) content and * ECDH-encrypts for delivery only. KING hand-sealed client-encrypt notes remain the * only true-E2E path. Mail ≠ mint: never writes chain balances. * Money-safety: call ONLY after money ops commit; always fail-open (try/catch). */ function nst_inbox_ec_ready(): bool { return function_exists('openssl_pkey_new') && function_exists('openssl_pkey_derive') && function_exists('openssl_encrypt') && function_exists('hash_hkdf') && defined('OPENSSL_KEYTYPE_EC'); } /** Export P-256 public key as SPKI hex (uncompressed). Fail → ''. */ function nst_inbox_spki_hex_from_pkey($pkey): string { if ($pkey === false || $pkey === null) return ''; $det = @openssl_pkey_get_details($pkey); if (!is_array($det)) return ''; if (isset($det['ec']['x'], $det['ec']['y'])) { $x = $det['ec']['x']; $y = $det['ec']['y']; if (strlen($x) < 32) $x = str_pad($x, 32, "\0", STR_PAD_LEFT); if (strlen($y) < 32) $y = str_pad($y, 32, "\0", STR_PAD_LEFT); if (strlen($x) > 32) $x = substr($x, -32); if (strlen($y) > 32) $y = substr($y, -32); $hdr = @hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200'); if ($hdr === false) return ''; return bin2hex($hdr . "\x04" . $x . $y); } $pem = (string)($det['key'] ?? ''); $b64 = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $pem) ?? ''; $bin = base64_decode($b64, true); return ($bin !== false && strlen($bin) > 40) ? bin2hex($bin) : ''; } /** * Server-side ECDH→HKDF→AES-GCM seal (same wire as client inbox v0). * @return array{eph_pub:string,iv:string,ct:string}|null */ function nst_inbox_server_ecdh_seal(string $recipientSpkiBin, string $plaintext): ?array { if (!nst_inbox_ec_ready() || strlen($recipientSpkiBin) < 50) return null; try { $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($recipientSpkiBin), 64, "\n") . "-----END PUBLIC KEY-----\n"; $their = @openssl_pkey_get_public($pem); if ($their === false) return null; $opts = ['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']; $cnf = getenv('OPENSSL_CONF'); if (is_string($cnf) && $cnf !== '' && is_file($cnf)) { $opts['config'] = $cnf; } $eph = @openssl_pkey_new($opts); if ($eph === false) return null; $shared = @openssl_pkey_derive($their, $eph); if ($shared === false || $shared === '') return null; $key = hash_hkdf('sha256', $shared, 32, 'nsu-inbox-v1', ''); if (!is_string($key) || strlen($key) !== 32) return null; $iv = random_bytes(12); $tag = ''; $ct = @openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); if ($ct === false || strlen($tag) !== 16) return null; $ephHex = nst_inbox_spki_hex_from_pkey($eph); if (strlen($ephHex) < 100) return null; return [ 'eph_pub' => strtolower($ephHex), 'iv' => bin2hex($iv), 'ct' => bin2hex($ct . $tag), ]; } catch (Throwable $e) { return null; } } /** * Best-effort deliver a server_notice to $toAddr if enc-pubkey registered. * Fail-open: any error → skipped; never throws; never touches balances. * @param array $publicFacts public book facts only (no seeds/keys) * @return array{ok:bool,skipped?:bool,reason?:string,id?:string} */ function nst_inbox_deliver_server_notice(string $toAddr, string $event, array $publicFacts): array { try { $to = preg_replace('/[^a-f0-9]/', '', strtolower($toAddr)) ?? ''; if (strlen($to) !== 64) { return ['ok' => false, 'skipped' => true, 'reason' => 'bad_addr']; } $spki = nst_load_registered_enc_spki($to); if ($spki === null) { return ['ok' => false, 'skipped' => true, 'reason' => 'no_enc_pubkey']; } // Strip anything that must never appear in a notice (defense in depth). $safeFacts = []; foreach ($publicFacts as $k => $v) { $kk = preg_replace('/[^a-z0-9_]/', '', strtolower((string)$k)) ?? ''; if ($kk === '' || in_array($kk, ['seed', 'secret', 'treasury_secret', 'priv', 'private_key', 'pkcs8'], true)) { continue; } if (is_scalar($v) || $v === null) { $safeFacts[$kk] = $v; } else { $safeFacts[$kk] = substr(j($v), 0, 400); } } $payload = [ 'kind' => 'server_notice', 'event' => $event, 'facts' => $safeFacts, 'note' => 'server-composed notice of a public book event — encrypted for delivery, not a private secret; the event is public on the book', 'e2e' => false, 'server_composed' => true, 'mail_not_mint' => true, ]; $plaintext = j($payload); if (strlen($plaintext) > 1800) { $plaintext = substr($plaintext, 0, 1800); } $sealed = nst_inbox_server_ecdh_seal($spki, $plaintext); if ($sealed === null) { return ['ok' => false, 'skipped' => true, 'reason' => 'crypto_unavailable']; } if (strlen($sealed['ct']) > NST_INBOX_MAX_CT_HEX) { return ['ok' => false, 'skipped' => true, 'reason' => 'ct_too_large']; } $ts = time(); $ttl = NST_INBOX_TTL_SECS; $exp = $ts + $ttl; $factId = (string)($safeFacts['id'] ?? $safeFacts['order_id'] ?? $safeFacts['quote_id'] ?? ''); $id = substr(hash('sha256', 'server_notice|v1|' . $event . '|' . $to . '|' . $factId . '|' . (string)$ts . '|' . bin2hex(random_bytes(4))), 0, 32); $from = ''; try { $from = strtolower(addr_from_seed(treasury_secret())); } catch (Throwable $e) { $from = str_repeat('0', 64); } $blob = [ 'v' => 1, 'id' => $id, 'to' => $to, 'from' => $from, 'ts' => $ts, 'ttl' => $ttl, 'exp' => $exp, 'eph_pub' => $sealed['eph_pub'], 'iv' => $sealed['iv'], 'ct' => $sealed['ct'], 'alg' => NST_INBOX_ALG, 'kind' => 'server_notice', 'event' => $event, 'mail_not_mint' => true, 'server_composed' => true, 'honesty' => 'server-composed notice of a public book event — encrypted for delivery, not a private secret; the event is public on the book', 'sig_mode' => 'server_mac', ]; try { $sec = treasury_secret(); $blob['sig'] = mac_sign($sec, nst_inbox_sign_body($blob)); } catch (Throwable $e) { $blob['sig'] = ''; } $path = nst_inbox_blob_path($to, $id); if (@file_put_contents($path, j($blob), LOCK_EX) === false) { return ['ok' => false, 'skipped' => true, 'reason' => 'storage']; } return ['ok' => true, 'id' => $id, 'to' => $to, 'kind' => 'server_notice', 'event' => $event]; } catch (Throwable $e) { return ['ok' => false, 'skipped' => true, 'reason' => 'exception']; } } /** After fill commits: notify maker + taker (public fill facts). Fail-open. */ function nst_inbox_notify_fill(array $fill): void { try { $facts = [ 'event' => 'fill', 'order_id' => (string)($fill['order_id'] ?? ''), 'pair' => (string)($fill['pair'] ?? ''), 'side' => (string)($fill['side'] ?? ''), 'amount' => (string)($fill['amount_fmt'] ?? $fill['amount'] ?? ''), 'price' => (string)($fill['price_fmt'] ?? $fill['price'] ?? ''), 'maker' => (string)($fill['maker'] ?? ''), 'taker' => (string)($fill['taker'] ?? ''), 'ts' => (int)($fill['ts'] ?? time()), 'id' => (string)($fill['order_id'] ?? '') . '|' . (string)($fill['ts'] ?? '') . '|' . (string)($fill['taker'] ?? ''), ]; $maker = preg_replace('/[^a-f0-9]/', '', strtolower((string)($fill['maker'] ?? ''))) ?? ''; $taker = preg_replace('/[^a-f0-9]/', '', strtolower((string)($fill['taker'] ?? ''))) ?? ''; if (strlen($maker) === 64) { nst_inbox_deliver_server_notice($maker, 'fill', $facts); } if (strlen($taker) === 64 && $taker !== $maker) { nst_inbox_deliver_server_notice($taker, 'fill', $facts); } } catch (Throwable $e) { // fail-open } } /** After half-BID arms: notify treasury-sell context (public book floor). Fail-open. */ function nst_inbox_notify_half_bid(?array $sellOrder, array $halfBid): void { try { if (!empty($halfBid['skipped'])) { return; } $facts = [ 'event' => 'half_bid_armed', 'half_bid_id' => (string)($halfBid['id'] ?? ''), 'paired_sell_id' => (string)($halfBid['paired_sell_id'] ?? ($sellOrder['id'] ?? '')), 'pair' => (string)($halfBid['pair'] ?? ($sellOrder['pair'] ?? '')), 'amount' => (string)($halfBid['amount_fmt'] ?? $halfBid['amount'] ?? ''), 'price' => (string)($halfBid['price_fmt'] ?? $halfBid['price'] ?? ''), 'side' => 'buy', 'order_kind' => 'treasury_half_bid', 'ts' => (int)($halfBid['ts'] ?? time()), 'id' => (string)($halfBid['id'] ?? ''), ]; $to = preg_replace('/[^a-f0-9]/', '', strtolower((string)($sellOrder['addr'] ?? $halfBid['addr'] ?? ''))) ?? ''; if (strlen($to) === 64) { nst_inbox_deliver_server_notice($to, 'half_bid_armed', $facts); } } catch (Throwable $e) { // fail-open } } /** After rent settle commits: notify payer. Fail-open. */ function nst_inbox_notify_rent_settle(array $settle): void { try { if (empty($settle['settled'])) { return; } $payer = preg_replace('/[^a-f0-9]/', '', strtolower((string)($settle['payer'] ?? ''))) ?? ''; $lease = is_array($settle['lease'] ?? null) ? $settle['lease'] : []; $facts = [ 'event' => 'rent_settle', 'site' => (string)($settle['site'] ?? $lease['site'] ?? ''), 'quote_id' => (string)($settle['quote_id'] ?? $lease['quote_id'] ?? ''), 'payer' => $payer, 'amount' => (string)($lease['amount_fmt'] ?? $lease['amount'] ?? ''), 'ts' => (int)($lease['rented_at'] ?? time()), 'id' => (string)($settle['quote_id'] ?? ''), ]; if (strlen($payer) === 64) { nst_inbox_deliver_server_notice($payer, 'rent_settle', $facts); } } catch (Throwable $e) { // fail-open } } /** After rent refund commits: notify payer. Fail-open. */ function nst_inbox_notify_rent_refund(?array $refund): void { try { if (!is_array($refund) || empty($refund['refunded'])) { return; } $tx = is_array($refund['tx'] ?? null) ? $refund['tx'] : []; $payer = preg_replace('/[^a-f0-9]/', '', strtolower((string)($tx['to'] ?? ''))) ?? ''; $facts = [ 'event' => 'rent_refund', 'quote_id' => (string)($refund['quote_id'] ?? $tx['quote_id'] ?? ''), 'reason' => (string)($refund['reason'] ?? $tx['rent_refund_reason'] ?? ''), 'amount' => (string)($tx['amount_fmt'] ?? $tx['amount'] ?? ''), 'payer' => $payer, 'ts' => (int)($tx['ts'] ?? time()), 'id' => (string)($tx['rent_refund_of_nonce'] ?? $tx['nonce'] ?? ''), ]; if (strlen($payer) === 64) { nst_inbox_deliver_server_notice($payer, 'rent_refund', $facts); } } catch (Throwable $e) { // fail-open } } /** * Dual-path actor: optional ECDSA seed-off-wire candidate OR the portable seed. * The twelve words are always root spending authority, including after an SPKI * was registered on another browser. ECDSA is an optimization, never a lockout. * An ECDSA candidate has an empty seed plus pubkey+ecdsa_sig; its address comes * from POST[$addrKey] (falling back to POST[from], then POST[addr]). Signature * verification happens later in nst_auth_sig(). * * @return array{addr:string,seed:?string,ecdsa:bool,spki_hex:string,ecdsa_sig:string} */ function nst_actor(string $addrKey = 'from'): array { $seedRaw = trim((string)($_POST['seed'] ?? '')); $spkiHex = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['pubkey'] ?? '')) ?? ''); $ecdsaSig = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['ecdsa_sig'] ?? '')) ?? ''); if ($seedRaw === '' && $spkiHex !== '' && $ecdsaSig !== '') { $addr = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST[$addrKey] ?? ''))) ?? ''; if (strlen($addr) !== 64) { $addr = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['from'] ?? ''))) ?? ''; } if (strlen($addr) !== 64) { $addr = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['addr'] ?? ''))) ?? ''; } if (strlen($addr) !== 64) { api_out(['ok' => false, 'err' => 'Need a 64-char hex wallet address'], 400); } $actor = [ 'addr' => $addr, 'seed' => null, 'ecdsa' => true, 'spki_hex' => $spkiHex, 'ecdsa_sig' => $ecdsaSig, ]; nst_device_wallet_guard_actor($actor); return $actor; } [$seed, $addr] = require_seed(); // The twelve words remain root spending authority on every device. A stored // optional ECDSA key may avoid sending them, but can never strand the seed. $actor = [ 'addr' => $addr, 'seed' => $seed, 'ecdsa' => false, 'spki_hex' => '', 'ecdsa_sig' => '', ]; nst_device_wallet_guard_actor($actor); return $actor; } /** * Verify signature over $body for an nst_actor() result. * ECDSA: registered SPKI + P-256 P1363 sig (override via $ecdsaSigOverride for secondary legs). * HMAC: optional client_sig ($clientSigKey) → browser_hmac_v1; else server_mac. * * @return array{sig_mode:string,sig:string,pubkey:string,seed_on_wire:bool} */ function nst_auth_sig(array $actor, string $body, string $clientSigKey = 'client_sig', ?string $ecdsaSigOverride = null): array { if (!empty($actor['ecdsa'])) { $spkiHex = (string)($actor['spki_hex'] ?? ''); $ecdsaSig = $ecdsaSigOverride !== null ? strtolower(preg_replace('/[^a-f0-9]/', '', $ecdsaSigOverride) ?? '') : (string)($actor['ecdsa_sig'] ?? ''); $spki = @hex2bin($spkiHex); if ($spki === false || strlen($spki) < 50) { api_out(['ok' => false, 'err' => 'Bad public key — check the key and try again'], 400); } $reg = nst_load_registered_spki((string)$actor['addr']); if ($reg === null || !hash_equals($reg, $spki)) { api_out(['ok' => false, 'err' => 'pubkey not registered — unlock with seed once'], 403); } if ($ecdsaSig === '' || !nst_ecdsa_verify_p256($spki, $body, $ecdsaSig)) { api_out(['ok' => false, 'err' => 'bad ecdsa signature'], 400); } return [ 'sig_mode' => 'ecdsa_p256_v1', 'sig' => $ecdsaSig, 'pubkey' => $spkiHex, 'seed_on_wire' => false, ]; } $seed = (string)($actor['seed'] ?? ''); if ($seed === '') { // P1-TRADE-MONEY-POST-PATH: missing auth = 401 plain JSON (not HTML face). api_out(['ok' => false, 'err' => 'Need your 12-word seed'], 401); } $expect = mac_sign($seed, $body); $clientSig = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST[$clientSigKey] ?? ''))) ?? ''; $sigMode = 'server_mac'; if ($clientSig !== '') { if (strlen($clientSig) !== 64 || !hash_equals($expect, $clientSig)) { api_out(['ok' => false, 'err' => 'bad client signature — re-unlock and retry'], 400); } $sigMode = 'browser_hmac_v1'; } return [ 'sig_mode' => $sigMode, 'sig' => $expect, 'pubkey' => '', 'seed_on_wire' => true, ]; } function dec_ok(string $a): bool { return (bool)preg_match('/^[0-9]+$/', $a); } function dec_norm(string $a): string { $a = ltrim($a, '0'); return $a === '' ? '0' : $a; } function dec_cmp(string $a, string $b): int { $a = dec_norm($a); $b = dec_norm($b); if (strlen($a) !== strlen($b)) return strlen($a) < strlen($b) ? -1 : 1; return $a <=> $b; } function dec_add(string $a, string $b): string { $a = dec_norm($a); $b = dec_norm($b); if (function_exists('bcadd')) return bcadd($a, $b, 0); $i = strlen($a) - 1; $j = strlen($b) - 1; $c = 0; $r = ''; while ($i >= 0 || $j >= 0 || $c) { $s = $c + ($i >= 0 ? (int)$a[$i--] : 0) + ($j >= 0 ? (int)$b[$j--] : 0); $r = (string)($s % 10) . $r; $c = intdiv($s, 10); } return dec_norm($r); } function dec_sub(string $a, string $b): string { if (dec_cmp($a, $b) < 0) return '-1'; if (function_exists('bcsub')) return bcsub(dec_norm($a), dec_norm($b), 0); $a = str_pad(dec_norm($a), max(strlen($a), strlen($b)), '0', STR_PAD_LEFT); $b = str_pad(dec_norm($b), strlen($a), '0', STR_PAD_LEFT); $c = 0; $r = ''; for ($i = strlen($a) - 1; $i >= 0; $i--) { $d = (int)$a[$i] - $c - (int)$b[$i]; if ($d < 0) { $d += 10; $c = 1; } else { $c = 0; } $r = (string)$d . $r; } return dec_norm($r); } function dec_mul_small(string $a, int $m): string { if (function_exists('bcmul')) return bcmul(dec_norm($a), (string)$m, 0); $a = dec_norm($a); $c = 0; $r = ''; for ($i = strlen($a) - 1; $i >= 0; $i--) { $p = (int)$a[$i] * $m + $c; $r = (string)($p % 10) . $r; $c = intdiv($p, 10); } while ($c > 0) { $r = (string)($c % 10) . $r; $c = intdiv($c, 10); } return dec_norm($r); } function fmt_amt(string $micros): string { $micros = dec_norm($micros); if (strlen($micros) <= NST_DECIMALS) { $whole = '0'; $frac = str_pad($micros, NST_DECIMALS, '0', STR_PAD_LEFT); } else { $whole = substr($micros, 0, -NST_DECIMALS); $frac = substr($micros, -NST_DECIMALS); } $frac = rtrim($frac, '0'); return $frac === '' ? $whole : "$whole.$frac"; } function parse_amt(string $h): ?string { $h = trim($h); if (!preg_match('/^\d+(\.\d{1,' . NST_DECIMALS . '})?$/', $h)) return null; if (strpos($h, '.') === false) return dec_mul_small($h, (int)str_pad('1', NST_DECIMALS + 1, '0')); [$w, $f] = explode('.', $h, 2); $f = str_pad($f, NST_DECIMALS, '0', STR_PAD_RIGHT); return dec_add(dec_mul_small($w === '' ? '0' : $w, (int)str_pad('1', NST_DECIMALS + 1, '0')), dec_norm($f)); } /* ---- TINY PURPOSE BRAINS (hand-crafted, in-file, no external deps) ---- * Each brain does ONE job. Sentience = many small coherent specialists, * not one cloud oracle. Fail open when unsure. */ /** Brain: memo_risk - bag-of-words scam/phishing score in [0,1]. Fail open. */ function nst_brain_memo_risk(string $text): array { $t = strtolower($text); $hits = []; $bad = [ 'seed phrase' => 0.35, 'private key' => 0.4, 'send all' => 0.25, 'double your' => 0.3, 'airdrop claim' => 0.25, 'connect wallet' => 0.2, 'verify wallet' => 0.25, 'urgent' => 0.1, 'guaranteed' => 0.15, '100x' => 0.2, 'giveaway' => 0.1, ]; $score = 0.0; foreach ($bad as $k => $w) { if ($k !== '' && str_contains($t, $k)) { $score += $w; $hits[] = $k; } } if ($score > 1.0) $score = 1.0; return ['brain' => 'memo_risk', 'score' => round($score, 3), 'hits' => $hits, 'flag' => $score >= 0.45]; } /** Brain: order_sanity - soft checks on side/amount/price; never blocks alone. */ function nst_brain_order_sanity(string $side, string $amount, string $price): array { $notes = []; $score = 0.0; if (!in_array($side, ['buy', 'sell'], true)) { $notes[] = 'side'; $score += 0.5; } if (dec_cmp($amount, '0') <= 0) { $notes[] = 'amount'; $score += 0.4; } if (dec_cmp($price, '0') <= 0) { $notes[] = 'price'; $score += 0.4; } // huge size relative to max supply micros is absurd if (strlen(dec_norm($amount)) > 18) { $notes[] = 'amount_huge'; $score += 0.3; } if ($score > 1.0) $score = 1.0; return ['brain' => 'order_sanity', 'score' => round($score, 3), 'notes' => $notes, 'flag' => $score >= 0.5]; } /** * Brain: wipe_copy + ephemerality reel - explicit yearly law for news-reel banner. * Segments are short so the reel can use larger type on Android (screen efficiency). * Coherence: same facts as gate risks / YEAR_WIPE vault note / EMPIRE_ECONOMICS. * * @return list */ function nst_ephemerality_reel_segments(int $daysLeft, ?array $extra = null): array { $d = max(0, $daysLeft); $segs = [ 'WARNING | NEXT YEARLY WIPE IN ' . $d . ' DAYS', 'EPHEMERALITY | this crop forgets on purpose every year', 'SURVIVES | ONLY your NSU wallet balances (ledger)', 'BURNS | profit pile | ad stakes | creatives | open orders | mods | panel loot', 'RE-KEYS | legacy panel password can die at wipe; site wallet seed is NOT auto-rotated (rotate yourself if leaked)', 'TREASURY | re-anchor is retired | coins enter only via daily emission', 'VALUE | THIS server owner sets local k / value note | not mirrors | not a global bank', 'MIRRORS | free tributaries only | help the network | zero cut | no value authority | hosting unpaid until parent-proven hits (no free daily host pay) | host because the swarm is hard to kill', 'EARLY DRIP | permanent 0.1 NSU/day only if you unlock in first 30 days after reset', 'SEED | your 12 words are the only password | no desk | no refunds', ]; if (is_array($extra)) { foreach ($extra as $s) { $s = trim((string)$s); if ($s !== '') $segs[] = $s; } } return $segs; } /** Single-line join (legacy callers / static SSR). */ function nst_brain_wipe_copy(int $daysLeft): string { return implode(' | ', nst_ephemerality_reel_segments($daysLeft)); } /** * Brain: ad_png - PNG magic + IHDR geometry + byte budget. * Hard reject: corrupt / wrong magic / out-of-range dimensions / oversize bytes. * Soft flag: extreme aspect or huge pixel count (still may serve). Fail open on soft only. */ function nst_brain_ad_png(string $bin): array { $len = strlen($bin); if ($len < 33) { return ['brain' => 'ad_png', 'ok' => false, 'err' => 'png too small', 'score' => 1.0, 'notes' => ['tiny'], 'flag' => true]; } if ($len > NST_AD_MAX_PNG) { return ['brain' => 'ad_png', 'ok' => false, 'err' => 'png too big (max ' . NST_AD_MAX_PNG . 'b)', 'score' => 1.0, 'notes' => ['bytes'], 'flag' => true, 'bytes' => $len]; } if (substr($bin, 0, 8) !== "\x89PNG\r\n\x1a\n") { return ['brain' => 'ad_png', 'ok' => false, 'err' => 'not a PNG', 'score' => 1.0, 'notes' => ['magic'], 'flag' => true]; } if (substr($bin, 12, 4) !== 'IHDR') { return ['brain' => 'ad_png', 'ok' => false, 'err' => 'missing IHDR', 'score' => 1.0, 'notes' => ['ihdr'], 'flag' => true]; } $w = unpack('N', substr($bin, 16, 4))[1]; $h = unpack('N', substr($bin, 20, 4))[1]; $bit = ord($bin[24]); $color = ord($bin[25]); if ($w < NST_AD_MIN_W || $h < NST_AD_MIN_H) { return ['brain' => 'ad_png', 'ok' => false, 'err' => 'min ' . NST_AD_MIN_W . 'x' . NST_AD_MIN_H, 'w' => $w, 'h' => $h, 'score' => 1.0, 'notes' => ['min'], 'flag' => true]; } if ($w > NST_AD_MAX_W || $h > NST_AD_MAX_H) { return ['brain' => 'ad_png', 'ok' => false, 'err' => 'max ' . NST_AD_MAX_W . 'x' . NST_AD_MAX_H, 'w' => $w, 'h' => $h, 'score' => 1.0, 'notes' => ['max'], 'flag' => true]; } $notes = []; $score = 0.0; $ratio = $w / max(1, $h); if ($ratio > 12.0 || $ratio < (1.0 / 12.0)) { $notes[] = 'extreme_aspect'; $score += 0.35; } if (($w * $h) > 2000000) { $notes[] = 'megapixels'; $score += 0.25; } if (!in_array($color, [0, 2, 3, 4, 6], true)) { $notes[] = 'color_type'; $score += 0.5; } if ($bit !== 8 && $bit !== 16 && $bit !== 4 && $bit !== 2 && $bit !== 1) { $notes[] = 'bit_depth'; $score += 0.2; } // Prefer board-friendly widths (soft) if ($w < 120 && $h < 60) { $notes[] = 'tiny_display'; $score += 0.15; } if ($score > 1.0) { $score = 1.0; } return [ 'brain' => 'ad_png', 'ok' => true, 'score' => round($score, 3), 'notes' => $notes, 'flag' => $score >= 0.5, 'w' => $w, 'h' => $h, 'bit_depth' => $bit, 'color_type' => $color, 'bytes' => $len, 'ratio' => round($ratio, 3), ]; } /** Brain: href_risk - click-through URL scheme + soft phishing tokens. Hard reject non-http(s). */ function nst_brain_href_risk(string $href): array { $href = trim($href); if ($href === '') { return ['brain' => 'href_risk', 'ok' => true, 'score' => 0.0, 'hits' => [], 'flag' => false]; } if (preg_match('#^(javascript|data|file|vbscript):#i', $href)) { return ['brain' => 'href_risk', 'ok' => false, 'err' => 'blocked scheme', 'score' => 1.0, 'hits' => ['scheme'], 'flag' => true]; } if (!preg_match('#^https?://#i', $href)) { return ['brain' => 'href_risk', 'ok' => false, 'err' => 'href must be http(s)', 'score' => 1.0, 'hits' => ['scheme'], 'flag' => true]; } $t = strtolower($href); $hits = []; $bad = [ 'seed' => 0.2, 'privatekey' => 0.3, 'wallet-connect' => 0.15, 'airdrop' => 0.15, 'double-your' => 0.25, 'free-money' => 0.2, 'bit.ly' => 0.1, 'tinyurl' => 0.1, ]; $score = 0.0; foreach ($bad as $k => $w) { if (str_contains($t, $k)) { $score += $w; $hits[] = $k; } } if ($score > 1.0) { $score = 1.0; } return ['brain' => 'href_risk', 'ok' => true, 'score' => round($score, 3), 'hits' => $hits, 'flag' => $score >= 0.45]; } /** Brain: pair_sane - soft book pair text (NSU/NOTE etc); never blocks alone. */ function nst_brain_pair_sane(string $pair): array { $p = strtoupper(trim($pair)); $notes = []; $score = 0.0; if ($p === '' || !preg_match('#^[A-Z0-9]{2,12}/[A-Z0-9]{2,12}$#', $p)) { $notes[] = 'format'; $score += 0.4; } if (str_contains($p, 'SEED') || str_contains($p, 'HTTP')) { $notes[] = 'token'; $score += 0.4; } if ($score > 1.0) { $score = 1.0; } return ['brain' => 'pair_sane', 'score' => round($score, 3), 'notes' => $notes, 'flag' => $score >= 0.5, 'pair' => $p]; } function boot_data(): void { global $DATA; if (!is_dir($DATA)) @mkdir($DATA, 0755, true); admin_pass_burn(); $ht = $DATA . '/.htaccess'; if (!is_file($ht)) @file_put_contents($ht, "Require all denied\nDeny from all\n"); $idx = $DATA . '/index.html'; if (!is_file($idx)) @file_put_contents($idx, ''); } function nst_genesis_pending_path(): string { global $DATA; return $DATA . DIRECTORY_SEPARATOR . 'genesis.pending'; } /** Established sidecars make a missing/empty ledger data loss, not virginity. */ function nst_chain_established_state_present(): bool { foreach (['TFILE', 'SITE_SEED_FILE', 'META', 'ECON', 'OP_ADDR_FILE', 'ADMIN_HASH_FILE', 'MODS_FILE'] as $name) { $path = $GLOBALS[$name] ?? ''; if (is_string($path) && $path !== '' && file_exists($path)) return true; } return false; } function nst_genesis_pending_valid(): bool { $path = nst_genesis_pending_path(); if (is_link($path) || !is_file($path)) return false; $raw = @file_get_contents($path); return is_string($raw) && hash_equals("NST-TRADE-GENESIS-PENDING-V1\n", $raw); } /** Called only while genesis.lock is held. */ function nst_genesis_pending_begin(): void { $path = nst_genesis_pending_path(); if (nst_genesis_pending_valid()) return; if (file_exists($path) || is_link($path) || nst_chain_established_state_present()) { throw new RuntimeException('chain_missing_established_state'); } $fh = @fopen($path, 'x+b'); if (!is_resource($fh)) throw new RuntimeException('genesis_unavailable'); try { @chmod($path, 0600); if (!nst_stream_write_all($fh, "NST-TRADE-GENESIS-PENDING-V1\n") || !nst_stream_flush_durable($fh) ) { throw new RuntimeException('genesis_unavailable'); } } finally { @fclose($fh); } } function nst_genesis_pending_finish(): void { $path = nst_genesis_pending_path(); if (!file_exists($path) && !is_link($path)) return; if (!nst_genesis_pending_valid() || !@unlink($path)) { throw new RuntimeException('genesis_unavailable'); } } /** Called only while genesis.lock is held, before the first strict chain read. */ function nst_chain_create_for_genesis(): void { global $CHAIN; if (file_exists($CHAIN)) { if (is_link($CHAIN) || !is_file($CHAIN)) throw new RuntimeException('chain_invalid'); $size = @filesize($CHAIN); if ($size === 0 && nst_chain_established_state_present() && !nst_genesis_pending_valid()) { throw new RuntimeException('chain_missing_established_state'); } return; } if (is_link($CHAIN) || (nst_chain_established_state_present() && !nst_genesis_pending_valid())) { throw new RuntimeException('chain_missing_established_state'); } $fh = @fopen($CHAIN, 'x+b'); if (!is_resource($fh)) throw new RuntimeException('genesis_unavailable'); try { @chmod($CHAIN, 0600); if (!nst_stream_flush_durable($fh)) throw new RuntimeException('genesis_unavailable'); } finally { @fclose($fh); } } /** Write every byte or fail; fwrite() may legally return a short count. */ function nst_stream_write_all($fh, string $bytes): bool { $length = strlen($bytes); $offset = 0; while ($offset < $length) { $written = @fwrite($fh, substr($bytes, $offset)); if (!is_int($written) || $written < 1) return false; $offset += $written; } return true; } /** Flush PHP and, where PHP exposes it, ask the OS to flush the file too. */ function nst_stream_flush_durable($fh): bool { if (!@fflush($fh)) return false; return !function_exists('fsync') || @fsync($fh); } /** * Strict JSONL reader for an already locked chain handle. * Blank, partial, malformed, or link-broken rows are corruption, never rows to * silently skip. $byteLimit reads exactly a known-good journal prefix. */ function nst_chain_read_locked_handle($fh, ?int $byteLimit = null, bool $verifyLinks = true): array { if (@rewind($fh) === false) throw new RuntimeException('chain_invalid'); $rows = []; $read = 0; while ($byteLimit === null || $read < $byteLimit) { $line = @fgets($fh); if ($line === false) { if (!@feof($fh) || ($byteLimit !== null && $read !== $byteLimit)) { throw new RuntimeException('chain_invalid'); } break; } $lineBytes = strlen($line); $read += $lineBytes; if (($byteLimit !== null && $read > $byteLimit) || $lineBytes < 2 || substr($line, -1) !== "\n" || str_contains($line, "\r") ) { throw new RuntimeException('chain_invalid'); } try { $decoded = json_decode(substr($line, 0, -1), true, 64, JSON_THROW_ON_ERROR); } catch (Throwable $e) { throw new RuntimeException('chain_invalid'); } if (!is_array($decoded) || $decoded === [] || nst_is_list_compat($decoded) || !is_string($decoded['type'] ?? null) || preg_match('/\A[a-z][a-z0-9_]{0,63}\z/D', (string)$decoded['type']) !== 1 ) { throw new RuntimeException('chain_invalid'); } foreach (array_keys($decoded) as $key) { if (!is_string($key)) throw new RuntimeException('chain_invalid'); } $rows[] = $decoded; } if ($byteLimit !== null && $read !== $byteLimit) { throw new RuntimeException('chain_invalid'); } if ($verifyLinks) { $link = nst_chain_link_verify($rows); if (empty($link['ok'])) throw new RuntimeException('chain_invalid'); } return $rows; } /** Remove only abandoned, uniquely named chain-journal staging files. */ function nst_chain_cleanup_journal_temps(): void { global $CHAIN_TXN; foreach ((array)(glob($CHAIN_TXN . '.*.tmp') ?: []) as $path) { if (!preg_match('/\Achain\.txn\.[0-9a-f]{24}\.tmp\z/D', basename($path))) continue; if (is_file($path) && !is_link($path)) @unlink($path); } } /** Remove a completed intent record or fail before reporting commit success. */ function nst_chain_remove_journal(): void { global $CHAIN_TXN; if (!file_exists($CHAIN_TXN)) { if (is_link($CHAIN_TXN)) throw new RuntimeException('chain_invalid'); return; } if (is_link($CHAIN_TXN) || !is_file($CHAIN_TXN) || !@unlink($CHAIN_TXN) || file_exists($CHAIN_TXN) || is_link($CHAIN_TXN) ) { throw new RuntimeException('chain_commit_failed'); } } /** Publish a durable intent record before touching chain.jsonl. */ function nst_chain_write_journal(array $journal): void { global $CHAIN_TXN; if (file_exists($CHAIN_TXN) || is_link($CHAIN_TXN)) { throw new RuntimeException('chain_recovery_required'); } nst_chain_cleanup_journal_temps(); $bytes = j($journal) . "\n"; $tmp = $CHAIN_TXN . '.' . bin2hex(random_bytes(12)) . '.tmp'; $fh = @fopen($tmp, 'x+b'); if (!is_resource($fh)) throw new RuntimeException('chain_commit_failed'); $ok = false; try { @chmod($tmp, 0600); if (!nst_stream_write_all($fh, $bytes) || !nst_stream_flush_durable($fh)) { throw new RuntimeException('chain_commit_failed'); } @fclose($fh); $fh = null; if (!@rename($tmp, $CHAIN_TXN)) throw new RuntimeException('chain_commit_failed'); $tmp = ''; @chmod($CHAIN_TXN, 0600); $saved = @file_get_contents($CHAIN_TXN); if (!is_string($saved) || !hash_equals($bytes, $saved)) { throw new RuntimeException('chain_commit_failed'); } $ok = true; } finally { if (is_resource($fh)) @fclose($fh); if (!$ok && $tmp !== '' && is_file($tmp) && !is_link($tmp)) @unlink($tmp); } } /** * Roll a durable intent forward. This is idempotent across crashes before, * during, or after the chain append: an observed payload prefix is completed; * unrelated bytes or a changed base fail closed. */ function nst_chain_recover_locked($fh): void { global $CHAIN_TXN; nst_chain_cleanup_journal_temps(); if (!file_exists($CHAIN_TXN)) { if (is_link($CHAIN_TXN)) throw new RuntimeException('chain_invalid'); return; } if (is_link($CHAIN_TXN) || !is_file($CHAIN_TXN)) throw new RuntimeException('chain_invalid'); $raw = @file_get_contents($CHAIN_TXN); $journal = is_string($raw) ? json_decode($raw, true) : null; if (!is_array($journal) || (int)($journal['v'] ?? 0) !== 1 || !is_int($journal['base_size'] ?? null) || (int)$journal['base_size'] < 0 || !is_string($journal['base_head'] ?? null) || !preg_match('/\A[0-9a-f]{64}\z/D', (string)$journal['base_head']) || !is_string($journal['payload_b64'] ?? null) || !is_string($journal['payload_sha256'] ?? null) || !is_string($journal['expected_head'] ?? null) ) { throw new RuntimeException('chain_invalid'); } $payload = base64_decode((string)$journal['payload_b64'], true); if (!is_string($payload) || $payload === '' || strlen($payload) > 16777216 || substr($payload, -1) !== "\n" || !hash_equals((string)$journal['payload_sha256'], hash('sha256', $payload)) || !preg_match('/\A[0-9a-f]{64}\z/D', (string)$journal['expected_head']) ) { throw new RuntimeException('chain_invalid'); } $baseSize = (int)$journal['base_size']; $baseRows = nst_chain_read_locked_handle($fh, $baseSize); if (!hash_equals((string)$journal['base_head'], nst_chain_head($baseRows))) { throw new RuntimeException('chain_invalid'); } $stat = @fstat($fh); $size = is_array($stat) ? (int)($stat['size'] ?? -1) : -1; $expectedSize = $baseSize + strlen($payload); if ($size < $baseSize || $size > $expectedSize) throw new RuntimeException('chain_invalid'); if (@fseek($fh, $baseSize, SEEK_SET) !== 0) throw new RuntimeException('chain_invalid'); $haveLen = $size - $baseSize; $have = $haveLen > 0 ? @stream_get_contents($fh, $haveLen) : ''; if (!is_string($have) || strlen($have) !== $haveLen || !hash_equals(substr($payload, 0, $haveLen), $have) ) { throw new RuntimeException('chain_invalid'); } if ($haveLen < strlen($payload)) { if (@fseek($fh, 0, SEEK_END) !== 0 || !nst_stream_write_all($fh, substr($payload, $haveLen)) ) { throw new RuntimeException('chain_commit_failed'); } } /* Even a full-length payload may have reached only the OS cache before the * writer died or reported fsync failure. Re-confirm durability on every * recovery path before deleting the only intent record. */ if (!nst_stream_flush_durable($fh)) throw new RuntimeException('chain_commit_failed'); $rows = nst_chain_read_locked_handle($fh); if (!hash_equals((string)$journal['expected_head'], nst_chain_head($rows))) { throw new RuntimeException('chain_invalid'); } nst_chain_remove_journal(); } /** Commit one or more linked rows under the caller's exclusive chain lock. */ function nst_chain_commit_rows_locked($fh, array &$rows, array $newRows): bool { if ($newRows === []) return true; if (function_exists('nst_conservation_is_frozen') && nst_conservation_is_frozen()) return false; $baseStat = @fstat($fh); $baseSize = is_array($baseStat) ? (int)($baseStat['size'] ?? -1) : -1; if ($baseSize < 0) return false; $working = $rows; $payload = ''; foreach ($newRows as $row) { if (!is_array($row) || $row === []) return false; /* Read the head THROUGH THE HANDLE WE ALREADY HOLD. * * The obvious version - nst_chain_head(read_chain()) - opens a SECOND handle * to a file this function has already flock'd exclusively. On Windows that * open fails, read_chain() returns [], and every row silently links to H0: * observed here as five consecutive rows all storing the genesis head, which * froze the chain on the next verify. It may well succeed on Linux, which is * worse - the bug would then only appear on somebody else's machine. * * $working was read from the open handle under LOCK_EX. The lock spans * head computation and write, so there is no TOCTOU. Do not call * read_chain() here. */ $head = nst_chain_head($working); if (array_key_exists('prev', $row)) { if (!is_string($row['prev']) || !hash_equals($head, $row['prev'])) return false; } else { $row['prev'] = $head; } $encoded = j($row) . "\n"; if (strlen($encoded) < 3) return false; $payload .= $encoded; if (strlen($payload) > 16777216) return false; $working[] = $row; } $journal = [ 'v' => 1, 'base_size' => $baseSize, 'base_head' => nst_chain_head($rows), 'payload_sha256' => hash('sha256', $payload), 'payload_b64' => base64_encode($payload), 'expected_head' => nst_chain_head($working), ]; nst_chain_write_journal($journal); if (@fseek($fh, 0, SEEK_END) !== 0 || !nst_stream_write_all($fh, $payload) || !nst_stream_flush_durable($fh) ) { // Durable intent now exists. Recovery either completes it or fails loud; // never report a clean rollback while the commit may still materialize. nst_chain_recover_locked($fh); } $verified = nst_chain_read_locked_handle($fh); if (!hash_equals((string)$journal['expected_head'], nst_chain_head($verified))) { throw new RuntimeException('chain_commit_failed'); } nst_chain_remove_journal(); $rows = $verified; return true; } function read_chain(bool $verifyLinks = true): array { global $CHAIN, $CHAIN_TXN; /* Reads must stay reads. A fresh pre-ENTER shell is allowed to observe an * empty ledger without manufacturing data/, chain.jsonl, or lock files. */ if (!file_exists($CHAIN)) { if (is_link($CHAIN) || file_exists($CHAIN_TXN) || is_link($CHAIN_TXN)) { throw new RuntimeException('chain_invalid'); } if (nst_chain_established_state_present() && !nst_genesis_pending_valid()) { throw new RuntimeException('chain_missing_established_state'); } return []; } if (is_link($CHAIN) || !is_file($CHAIN)) throw new RuntimeException('chain_invalid'); $fh = @fopen($CHAIN, 'r+b'); if (!is_resource($fh)) throw new RuntimeException('chain_unavailable'); if (!@flock($fh, LOCK_EX)) { @fclose($fh); throw new RuntimeException('chain_unavailable'); } try { nst_chain_recover_locked($fh); $rows = nst_chain_read_locked_handle($fh, null, $verifyLinks); if ($rows === [] && nst_chain_established_state_present() && !nst_genesis_pending_valid()) { throw new RuntimeException('chain_missing_established_state'); } return $rows; } finally { @flock($fh, LOCK_UN); @fclose($fh); } } function append_row(array $row): bool { $result = nst_with_chain_lock(static function (array $rows, callable $append) use ($row): bool { return $append($row); }); return $result === true; } /** * Exclusive chain read-modify-write. Holds LOCK_EX for the whole callback so * fill/faucet/transfer/order/admin_pay/extract/lazy_accrue races cannot double-spend. * Callback: fn(array $rows, callable(array):bool $append, callable(array):bool $appendBatch): mixed * Do NOT call append_row() inside — use $append only (same fd / non-deadlocking). * Returns null if the chain file cannot be opened/locked. */ function nst_with_chain_lock(callable $fn) { global $CHAIN; boot_data(); if (!file_exists($CHAIN) || is_link($CHAIN) || !is_file($CHAIN)) return null; $fh = @fopen($CHAIN, 'r+b'); if (!$fh) return null; if (!flock($fh, LOCK_EX)) { fclose($fh); return null; } try { nst_chain_recover_locked($fh); $rows = nst_chain_read_locked_handle($fh); if ($rows === [] && nst_chain_established_state_present() && !nst_genesis_pending_valid()) { throw new RuntimeException('chain_missing_established_state'); } $append = function (array $row) use ($fh, &$rows): bool { return nst_chain_commit_rows_locked($fh, $rows, [$row]); }; $appendBatch = function (array $batch) use ($fh, &$rows): bool { return nst_chain_commit_rows_locked($fh, $rows, $batch); }; return $fn($rows, $append, $appendBatch); } finally { flock($fh, LOCK_UN); fclose($fh); } } /** * Exclusive file lock helper (durable files: rent_quotes, etc.). * Callback: fn(resource $fh): mixed — file opened c+b, pointer undefined. * Free claim faucet retired 410 — no cooldown file. */ function nst_with_file_lock(string $path, callable $fn) { $dir = dirname($path); if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $fh = @fopen($path, 'c+b'); if (!$fh) return null; if (!flock($fh, LOCK_EX)) { fclose($fh); return null; } try { return $fn($fh); } finally { flock($fh, LOCK_UN); fclose($fh); } } function balances(array $rows): array { $b = []; foreach ($rows as $r) { $t = $r['type'] ?? ''; if (nst_is_mint_type($t)) { foreach ($r['outputs'] ?? [] as $o) { $a = $o['addr'] ?? ''; $m = $o['amount'] ?? '0'; if ($a && dec_ok($m)) $b[$a] = dec_add($b[$a] ?? '0', $m); } // explicit treasury burn leg (excess liquid above 24_000_000 NSU KING target) if ($t === 'treasury_reanchor' && !empty($r['burn_amount']) && dec_ok((string)$r['burn_amount'])) { $ta = (string)($r['treasury_addr'] ?? ''); $bm = (string)$r['burn_amount']; if ($ta !== '' && dec_cmp($b[$ta] ?? '0', $bm) >= 0) { $b[$ta] = dec_sub($b[$ta] ?? '0', $bm); $burn = NST_BURN_ADDR; $b[$burn] = dec_add($b[$burn] ?? '0', $bm); } } } elseif ($t === 'transfer') { $f = $r['from'] ?? ''; $to = $r['to'] ?? ''; $m = $r['amount'] ?? '0'; if (!$f || !$to || !dec_ok($m)) continue; if (dec_cmp($b[$f] ?? '0', $m) < 0) continue; // skip invalid historical $b[$f] = dec_sub($b[$f] ?? '0', $m); $b[$to] = dec_add($b[$to] ?? '0', $m); } elseif ($t === 'ad_stake') { /* One payer, several payees (lord + King's tax). NOT a mint: the legs * must sum to exactly what the payer spends, and this branch is what * makes that true. Without it the row would be inert - the payer * never debited, the lord never paid - and the sink would be theatre. * The sum is re-checked here rather than trusted from the writer, * because balances() is replayed against chains this build did not * necessarily produce. */ $f = $r['from'] ?? ''; $m = (string)($r['amount'] ?? '0'); if (!$f || !dec_ok($m)) continue; $sum = '0'; foreach ($r['outputs'] ?? [] as $o) { if (!is_array($o)) continue; $am = (string)($o['amount'] ?? '0'); if (dec_ok($am)) $sum = dec_add($sum, $am); } if (dec_cmp($sum, $m) !== 0) continue; // malformed: creates or destroys money if (dec_cmp($b[$f] ?? '0', $m) < 0) continue; // unfunded: skip like a bad transfer $b[$f] = dec_sub($b[$f] ?? '0', $m); foreach ($r['outputs'] as $o) { $a = (string)($o['addr'] ?? ''); $am = (string)($o['amount'] ?? '0'); if ($a !== '' && dec_ok($am)) $b[$a] = dec_add($b[$a] ?? '0', $am); } } } return $b; } /* ======================================================================== * E2 CONSERVATION INVARIANT + FREEZE + int-micros audit (NSU-V1-SIMPLE STEP 1) * ADDITIVE ONLY. Does not alter balances()/transfer/faucet/order bodies. * On mismatch or non-integer money fields: sticky FREEZE file → all chain * writes reject → write APIs 503 conservation_freeze + loud red banner. * ======================================================================== */ /** True iff $m is a non-negative integer decimal string (micros). Same law as dec_ok. */ function nst_int_micros_ok(string $m): bool { return dec_ok($m); } /** * Audit money-bearing fields on chain rows for non-integer micros. * @return array{ok:bool,violations:list} */ function nst_audit_int_micros(array $rows): array { $violations = []; $check = static function (int $i, string $type, string $field, $raw) use (&$violations): void { if ($raw === null) { $violations[] = ['i' => $i, 'type' => $type, 'field' => $field, 'value' => '']; return; } $v = is_string($raw) || is_int($raw) ? (string)$raw : ''; if ($v === '' || !nst_int_micros_ok($v)) { $violations[] = ['i' => $i, 'type' => $type, 'field' => $field, 'value' => $v]; } }; foreach ($rows as $i => $r) { if (!is_array($r)) { continue; } $t = (string)($r['type'] ?? ''); if (nst_is_mint_type($t)) { foreach ($r['outputs'] ?? [] as $j => $o) { if (!is_array($o)) { continue; } $check((int)$i, $t, 'outputs[' . $j . '].amount', $o['amount'] ?? null); } if ($t === 'treasury_reanchor' && array_key_exists('burn_amount', $r)) { $check((int)$i, $t, 'burn_amount', $r['burn_amount']); } } elseif ($t === 'transfer' || $t === 'fill') { if (array_key_exists('amount', $r)) { $check((int)$i, $t, 'amount', $r['amount']); } } elseif ($t === 'ad_stake') { /* Every money field, including each payout leg. A non-integer amount * anywhere is what the E2 freeze exists to catch. */ if (array_key_exists('amount', $r)) { $check((int)$i, $t, 'amount', $r['amount']); } foreach ($r['outputs'] ?? [] as $j => $o) { if (!is_array($o)) continue; $check((int)$i, $t, 'outputs[' . $j . '].amount', $o['amount'] ?? null); } } elseif ($t === 'order') { if (array_key_exists('amount', $r)) { $check((int)$i, $t, 'amount', $r['amount']); } if (array_key_exists('price', $r)) { $check((int)$i, $t, 'price', $r['price']); } } } return ['ok' => $violations === [], 'violations' => $violations]; } /** * Replay conservation: expected issued vs Σ balances(). * Expected = genesis outputs + treasury_reanchor credit outputs only. * Burn stays in total (NST_BURN_ADDR). Transfers conserve. * * @return array{ok:bool,observed_sum:string,expected_issued:string,balances_sum:string,int_ok:bool,detail:array} */ function nst_conservation_replay(array $rows): array { $expected = '0'; $genesisN = 0; $genesisSum = '0'; $reanchorCreditN = 0; $reanchorCreditSum = '0'; $emissionN = 0; $emissionSum = '0'; foreach ($rows as $r) { if (!is_array($r)) { continue; } $t = (string)($r['type'] ?? ''); /* Any declared mint type counts toward expected issuance. Using the * registry means a new mint type cannot be added without conservation * seeing it - the alternative desyncs the audit and trips the E2 freeze. */ if ($t === 'emission') { foreach ($r['outputs'] ?? [] as $o) { if (!is_array($o)) continue; $m = (string)($o['amount'] ?? '0'); if (!nst_int_micros_ok($m) || dec_cmp($m, '0') <= 0) continue; $expected = dec_add($expected, $m); $emissionSum = dec_add($emissionSum, $m); $emissionN++; } } elseif ($t === 'genesis') { $genesisN++; foreach ($r['outputs'] ?? [] as $o) { if (!is_array($o)) { continue; } $m = (string)($o['amount'] ?? '0'); if (!nst_int_micros_ok($m)) { continue; } $expected = dec_add($expected, $m); $genesisSum = dec_add($genesisSum, $m); } } elseif ($t === 'treasury_reanchor') { foreach ($r['outputs'] ?? [] as $o) { if (!is_array($o)) { continue; } $m = (string)($o['amount'] ?? '0'); if (!nst_int_micros_ok($m) || dec_cmp($m, '0') <= 0) { continue; } $expected = dec_add($expected, $m); $reanchorCreditSum = dec_add($reanchorCreditSum, $m); $reanchorCreditN++; } } } $bal = balances($rows); $observed = '0'; $nonzeroAddrs = 0; foreach ($bal as $addr => $amt) { $a = (string)$amt; if (!nst_int_micros_ok($a)) { return [ 'ok' => false, 'observed_sum' => '0', 'expected_issued' => $expected, 'balances_sum' => '0', 'int_ok' => false, 'detail' => [ 'err' => 'balance_non_int', 'addr' => (string)$addr, 'amount' => $a, ], ]; } $observed = dec_add($observed, $a); if (dec_cmp($a, '0') > 0) { $nonzeroAddrs++; } } $intAudit = nst_audit_int_micros($rows); $match = dec_cmp($observed, $expected) === 0; $ok = $match && !empty($intAudit['ok']); $diff = '0'; if (!$match) { $diff = dec_cmp($observed, $expected) > 0 ? dec_sub($observed, $expected) : ('-' . dec_sub($expected, $observed)); } return [ 'ok' => $ok, 'observed_sum' => $observed, 'expected_issued' => $expected, 'balances_sum' => $observed, 'int_ok' => !empty($intAudit['ok']), 'detail' => [ 'match' => $match, 'diff' => $diff, 'genesis_rows' => $genesisN, 'genesis_sum' => $genesisSum, 'reanchor_credit_legs' => $reanchorCreditN, 'reanchor_credit_sum' => $reanchorCreditSum, 'emission_legs' => $emissionN, 'emission_sum' => $emissionSum, 'nonzero_addrs' => $nonzeroAddrs, 'burn_addr' => NST_BURN_ADDR, 'burn_bal' => (string)($bal[NST_BURN_ADDR] ?? '0'), 'int_violations' => $intAudit['violations'], 'note' => 'expected=genesis+reanchor credits; burn moves to NST_BURN_ADDR (stays in total)', ], ]; } /* ======================================================================== * CHAIN LINKAGE — make the chain an actual chain. * * chain.jsonl was an append-only LOG, not a chain: every row was individually * signed, but the SEQUENCE was unauthenticated. No row committed to any earlier * row, so deleting line 4 left no evidence that line 4 had ever existed. * * Each row now carries prev = the running head before it: * * H0 = sha256("nsu-chain-v1") * Hn = sha256(H(n-1) | canonical(row_n)) * * WHAT THIS CATCHES, and therefore invalidates: modification, insertion, * mid-deletion and reordering. All four become arithmetic rather than opinion. * * WHAT IT DOES NOT CATCH: truncation of the TAIL. Chop the last N rows and what * remains is a shorter chain that verifies perfectly, because nothing left is * pointing at what was removed. Anyone claiming a hash chain alone prevents * rollback is wrong. That needs an external witness - the published head, and * counterparties who kept a copy. * * MIGRATION IS THE DANGEROUS PART. Chains written before this existed have no * prev field at all, and hard-failing on that would brick every running empire * on upgrade. So a row WITHOUT prev is treated as pre-linkage: it still advances * the head (or later rows could never link), it is simply not checked. Linkage * begins at the first row that carries it and is enforced from there on. * ======================================================================== */ /** Deterministic bytes for a row, excluding the link fields themselves. */ function nst_row_canonical(array $row): string { unset($row['prev']); ksort($row); // key order must not depend on insertion order return j($row); } /** Fold the running head over a list of rows. */ function nst_chain_head(array $rows): string { $h = hash('sha256', 'nsu-chain-v1'); foreach ($rows as $r) { if (!is_array($r)) continue; $h = hash('sha256', $h . '|' . nst_row_canonical($r)); } return $h; } /** * Verify linkage across a chain. * @return array{ok:bool,head:string,checked:int,legacy:int,break_at:?int,expected?:string,found?:string} */ function nst_chain_link_verify(array $rows): array { $h = hash('sha256', 'nsu-chain-v1'); $checked = 0; $legacy = 0; foreach ($rows as $i => $r) { if (!is_array($r)) continue; if (array_key_exists('prev', $r)) { if (!is_string($r['prev']) || !hash_equals($h, $r['prev'])) { return ['ok' => false, 'head' => $h, 'checked' => $checked, 'legacy' => $legacy, 'break_at' => (int)$i, 'expected' => $h, 'found' => (string)($r['prev'] ?? '')]; } $checked++; } else { $legacy++; // pre-linkage row: advances the head, not verified } $h = hash('sha256', $h . '|' . nst_row_canonical($r)); } return ['ok' => true, 'head' => $h, 'checked' => $checked, 'legacy' => $legacy, 'break_at' => null]; } /** * AUDIT ANY EMPIRE'S CHAIN WITH THE CODE THAT MAINTAINS YOUR OWN. * * Kings evolve their crops separately - that is the design - so you can never * verify a peer's CODE. ?api=selfhash returns a hash the audited file computes * about itself, which is worth nothing against a King who edits the file. Any * fingerprint, attestation or badge has the same flaw: it is self-report. * * So do not audit the code. Audit the LEDGER, which is arithmetic on data the * peer published: * * ceiling sum of every mint leg <= NST_MAX_SUPPLY * emission each day == (MAX - minted_before) * rate / 1e6 * split equal share per crop, last absorbing dust, King's cut off top * conservation sum of balances == sum of mints * linkage prev hashes fold correctly * * None of that is a vote, so no coalition can capture it: every reader checks * alone and reaches the same answer. This is also why POLICY should be OBSERVED * rather than declared - an emission row carries the rate and split it actually * used, which is testimony against interest, where a constants list a peer * hands you is just a claim. * * Two verdicts, deliberately separate: * internally_consistent the peer obeyed its OWN declared rules * policy_match those rules are also YOUR rules * A peer can be perfectly honest and still be on different money. Conflating * the two would either reject honest neighbours or accept forgers. * * WHAT THIS CANNOT DO: prove the history happened. A fabricated chain that * obeys every rule costs nothing to produce. Only a counterparty's own record * of your head over time can catch that, and only for the period they recorded. */ function nst_verify_chain(array $rows): array { $link = nst_chain_link_verify($rows); $cons = nst_conservation_replay($rows); $minted = '0'; $emissions = 0; $badFormula = []; $badSplit = []; $rateSeen = []; $cropCounts = []; $overCeiling = false; foreach ($rows as $i => $r) { if (!is_array($r) || ($r['type'] ?? '') !== 'emission') { if (is_array($r) && nst_is_mint_type($r['type'] ?? '')) { foreach ($r['outputs'] ?? [] as $o) { $m = (string)($o['amount'] ?? '0'); if (dec_ok($m)) $minted = dec_add($minted, $m); } } continue; } $emissions++; $rate = (int)($r['rate_ppm'] ?? 0); $rateSeen[$rate] = true; /* The formula, checked against the supply BEFORE this row. */ $expect = dec_div_small(dec_mul_small(dec_sub(NST_MAX_SUPPLY, $minted), $rate), 1000000); $legSum = '0'; foreach ($r['outputs'] ?? [] as $o) { $m = (string)($o['amount'] ?? '0'); if (dec_ok($m)) $legSum = dec_add($legSum, $m); } if (dec_cmp($legSum, $expect) !== 0) { $badFormula[] = ['row' => (int)$i, 'declared' => $legSum, 'formula' => $expect]; } /* The split: crop legs plus one aggregate King leg. */ $n = (int)($r['crops_paid'] ?? 0); $cropCounts[$n] = true; $per = (string)($r['per_crop_micros'] ?? '0'); $tax = (string)($r['king_tax_micros'] ?? '0'); if ($n > 0 && dec_ok($per)) { $grossAll = dec_mul_small($per, $n); /* King's declared take must match the declared tax rate on the gross. */ $taxPpm = (int)($r['king_tax_ppm'] ?? 0); $expectTax = dec_div_small(dec_mul_small($grossAll, $taxPpm), 1000000); /* Allow the per-share rounding floor: each share may round up by <1 micro. */ $slack = (string)$n; if (dec_cmp($tax, $expectTax) < 0 || dec_cmp(dec_sub($tax, $expectTax), $slack) > 0) { $badSplit[] = ['row' => (int)$i, 'king_tax' => $tax, 'expected_about' => $expectTax]; } } $minted = dec_add($minted, $legSum); } if (dec_cmp($minted, NST_MAX_SUPPLY) > 0) $overCeiling = true; $internal = $link['ok'] && !empty($cons['ok']) && !$badFormula && !$badSplit && !$overCeiling; $policy = (!$rateSeen || array_keys($rateSeen) === [NST_EMISSION_DAILY_RATE_PPM]) && (!$cropCounts || array_keys($cropCounts) === [NST_EMISSION_CROP_COUNT]); return [ 'rows' => count($rows), 'internally_consistent' => $internal, 'policy_match' => (bool)$policy, 'linkage' => ['ok' => $link['ok'], 'break_at' => $link['break_at'], 'checked' => $link['checked'], 'legacy' => $link['legacy'], 'head' => $link['head']], 'conservation' => ['ok' => !empty($cons['ok']), 'observed' => (string)$cons['observed_sum'], 'expected' => (string)$cons['expected_issued']], 'supply' => ['minted' => $minted, 'ceiling' => NST_MAX_SUPPLY, 'over_ceiling' => $overCeiling], 'emissions' => $emissions, 'observed_rate_ppm' => array_values(array_map('intval', array_keys($rateSeen))), 'observed_crop_count' => array_values(array_map('intval', array_keys($cropCounts))), 'my_rate_ppm' => NST_EMISSION_DAILY_RATE_PPM, 'my_crop_count' => NST_EMISSION_CROP_COUNT, 'formula_breaks' => $badFormula, 'split_breaks' => $badSplit, 'note' => 'internally_consistent = the peer obeyed its own declared rules. ' . 'policy_match = those rules are also ours. Neither proves the history happened; ' . 'only your own recorded heads over time can catch a fabricated past.', ]; } function nst_conservation_freeze_path(): string { global $DATA; return $DATA . DIRECTORY_SEPARATOR . 'conservation.freeze.json'; } /** @return array{frozen?:bool,ts?:int,reason?:string,observed?:string,expected?:string}|null */ function nst_conservation_freeze_read(): ?array { $p = nst_conservation_freeze_path(); if (!is_file($p)) { return null; } $j = json_decode((string)@file_get_contents($p), true); return is_array($j) ? $j : ['frozen' => true, 'reason' => 'unreadable_freeze_file']; } function nst_conservation_is_frozen(): bool { $j = nst_conservation_freeze_read(); if ($j === null) { return false; } // Fail closed: freeze file present with missing frozen key still blocks. if (array_key_exists('frozen', $j)) { return !empty($j['frozen']); } return true; } /** Sticky freeze until operator deletes file (no public unfreeze API). */ function nst_conservation_freeze_set(array $report, string $reason = 'conservation_mismatch'): bool { boot_data(); $payload = [ 'frozen' => true, 'ts' => time(), 'reason' => $reason, 'observed' => (string)($report['observed_sum'] ?? $report['observed'] ?? '0'), 'expected' => (string)($report['expected_issued'] ?? $report['expected'] ?? '0'), 'detail' => $report['detail'] ?? null, 'version' => NST_VERSION, ]; return @file_put_contents(nst_conservation_freeze_path(), j($payload), LOCK_EX) !== false; } /** Operator/test only — remove freeze file. Not auto-called on healthy check. */ function nst_conservation_freeze_clear(): bool { $p = nst_conservation_freeze_path(); if (!is_file($p)) { return true; } return @unlink($p); } /** * Run conservation replay; FREEZE on mismatch or int-micros fail. * Sticky: never auto-clears freeze (silent heal forbidden). */ function nst_conservation_check_and_maybe_freeze(array $rows): array { $rep = nst_conservation_replay($rows); $rep['checked_ts'] = time(); $rep['frozen'] = nst_conservation_is_frozen(); $rep['freeze_written'] = false; /* STRUCTURE BEFORE SUMS. * Conservation adds up the rows; there is no point adding up rows whose * ORDER you cannot trust. A linkage break means somebody edited, inserted, * reordered or removed history - which invalidates the sequence the totals * were computed over, even when the totals happen to still balance. So this * runs first and freezes on its own, with its own reason. */ $link = nst_chain_link_verify($rows); $rep['link'] = ['ok' => $link['ok'], 'head' => $link['head'], 'checked' => $link['checked'], 'legacy' => $link['legacy'], 'break_at' => $link['break_at']]; if (empty($link['ok'])) { $rep['ok'] = false; $rep['freeze_written'] = nst_conservation_freeze_set($rep, 'chain_link_break'); $rep['frozen'] = true; } if (empty($rep['ok']) && empty($rep['freeze_written'])) { $why = empty($rep['int_ok']) ? 'int_micros_violation' : 'conservation_mismatch'; $rep['freeze_written'] = nst_conservation_freeze_set($rep, $why); $rep['frozen'] = true; } // Best-effort meta stamp (freeze file is authority). $m = load_meta(); $m['last_conservation_ts'] = (int)$rep['checked_ts']; $m['last_conservation_ok'] = !empty($rep['ok']); $m['last_conservation_observed'] = (string)$rep['observed_sum']; $m['last_conservation_expected'] = (string)$rep['expected_issued']; save_meta($m); return $rep; } /** Early reject for write APIs. 503 + err conservation_freeze. */ function nst_require_writes_unfrozen(): void { if (!nst_conservation_is_frozen()) { return; } $fr = nst_conservation_freeze_read() ?? []; api_out([ 'ok' => false, 'err' => 'Trading is paused while the ledger is checked — try again shortly', 'frozen' => true, 'reason' => (string)($fr['reason'] ?? 'conservation_mismatch'), 'observed_fmt' => isset($fr['observed']) && nst_int_micros_ok((string)$fr['observed']) ? fmt_amt((string)$fr['observed']) : null, 'expected_fmt' => isset($fr['expected']) && nst_int_micros_ok((string)$fr['expected']) ? fmt_amt((string)$fr['expected']) : null, 'freeze_ts' => (int)($fr['ts'] ?? 0), 'note' => 'WRITES FROZEN — ledger conservation check failed on this host. Transfers, claims, and book posts are paused until the chain balances match again. Read-only view still works. Experimental rails — not insured, not a bank.', ], 503); } /** * Throttled boot check. Always honors freeze file. * @return array public-safe conservation snapshot */ function nst_conservation_boot_check(array $rows, bool $force = false): array { $frozen = nst_conservation_is_frozen(); $meta = load_meta(); $last = (int)($meta['last_conservation_ts'] ?? 0); $age = $last > 0 ? (time() - $last) : PHP_INT_MAX; $need = $force || $last < 1 || $age >= NST_CONSERVATION_RECHECK_SECS; if ($frozen && !$force) { $fr = nst_conservation_freeze_read() ?? []; return [ 'ok' => false, 'frozen' => true, 'sum_fmt' => isset($fr['observed']) && nst_int_micros_ok((string)$fr['observed']) ? fmt_amt((string)$fr['observed']) : null, 'expected_fmt' => isset($fr['expected']) && nst_int_micros_ok((string)$fr['expected']) ? fmt_amt((string)$fr['expected']) : null, 'checked_ts' => (int)($fr['ts'] ?? $last), 'throttled' => true, 'reason' => (string)($fr['reason'] ?? 'conservation_freeze'), ]; } if (!$need && !$frozen) { $obs = (string)($meta['last_conservation_observed'] ?? ''); $exp = (string)($meta['last_conservation_expected'] ?? ''); $okMeta = !empty($meta['last_conservation_ok']); if ($obs !== '' && $exp !== '' && nst_int_micros_ok($obs) && nst_int_micros_ok($exp)) { return [ 'ok' => $okMeta, 'frozen' => false, 'sum_fmt' => fmt_amt($obs), 'expected_fmt' => fmt_amt($exp), 'checked_ts' => $last, 'throttled' => true, ]; } } $rep = nst_conservation_check_and_maybe_freeze($rows); return [ 'ok' => !empty($rep['ok']), 'frozen' => !empty($rep['frozen']), 'sum_fmt' => fmt_amt((string)$rep['observed_sum']), 'expected_fmt' => fmt_amt((string)$rep['expected_issued']), 'checked_ts' => (int)$rep['checked_ts'], 'throttled' => false, 'int_ok' => !empty($rep['int_ok']), 'detail' => $rep['detail'] ?? null, ]; } /** Public-facing conservation object for api=state / HTML. */ function nst_conservation_public_view(?array $bootSnap = null): array { if (is_array($bootSnap)) { return [ 'ok' => !empty($bootSnap['ok']), 'frozen' => !empty($bootSnap['frozen']) || nst_conservation_is_frozen(), 'sum_fmt' => $bootSnap['sum_fmt'] ?? null, 'expected_fmt' => $bootSnap['expected_fmt'] ?? null, 'checked_ts' => (int)($bootSnap['checked_ts'] ?? 0), ]; } $frozen = nst_conservation_is_frozen(); $fr = $frozen ? (nst_conservation_freeze_read() ?? []) : []; $meta = load_meta(); $obs = (string)($fr['observed'] ?? $meta['last_conservation_observed'] ?? ''); $exp = (string)($fr['expected'] ?? $meta['last_conservation_expected'] ?? ''); return [ 'ok' => !$frozen && !empty($meta['last_conservation_ok']), 'frozen' => $frozen, 'sum_fmt' => ($obs !== '' && nst_int_micros_ok($obs)) ? fmt_amt($obs) : null, 'expected_fmt' => ($exp !== '' && nst_int_micros_ok($exp)) ? fmt_amt($exp) : null, 'checked_ts' => (int)($fr['ts'] ?? $meta['last_conservation_ts'] ?? 0), ]; } /** * World population metadata only (not used for supply scale). * Liquid genesis / re-anchor = fixed 24_000_000 NSU KING faucet. * Fail-open sources: * 1) data/world_pop.override (operator integer — durable, not ephemeral) * 2) ephemeral world_pop.json cache if fresh (prefer /dev/shm; else data/ephemeral/) * 3) crawl allowlisted public stats (World Bank WLD SP.POP.TOTL) * 4) NST_POP_FALLBACK constant * Never blocks wipe/genesis if network dies — fixed 24_000_000 NSU still lands. * Cache is regenerable L3 only; losing it triggers recompute — never money SoT. * * @return array{pop:int,source:string,ts:int,cached:bool} */ function nst_world_pop_cache_path(): string { return nst_ephemeral_dir() . DIRECTORY_SEPARATOR . 'world_pop.json'; } function nst_world_population(): array { global $DATA; boot_data(); $override = $DATA . DIRECTORY_SEPARATOR . 'world_pop.override'; if (is_file($override)) { $n = (int)trim((string)@file_get_contents($override)); if ($n >= 1000000 && $n <= 50000000000) { return ['pop' => $n, 'source' => 'override', 'ts' => time(), 'cached' => false]; } } $cache = nst_world_pop_cache_path(); if (is_file($cache)) { $j = json_decode((string)@file_get_contents($cache), true); if (is_array($j) && !empty($j['pop']) && !empty($j['ts'])) { $age = time() - (int)$j['ts']; if ($age >= 0 && $age < NST_POP_CACHE_SECS && (int)$j['pop'] >= 1000000) { return [ 'pop' => (int)$j['pop'], 'source' => (string)($j['source'] ?? 'cache'), 'ts' => (int)$j['ts'], 'cached' => true, ]; } } } $crawled = nst_crawl_world_population(); if ($crawled !== null) { $edir = dirname($cache); if (!is_dir($edir)) { @mkdir($edir, 0755, true); } @file_put_contents($cache, j($crawled), LOCK_EX); return $crawled + ['cached' => false]; } return [ 'pop' => NST_POP_FALLBACK, 'source' => 'fallback_constant', 'ts' => time(), 'cached' => false, ]; } /** @return array{pop:int,source:string,ts:int}|null */ function nst_crawl_world_population(): ?array { if (!ini_get('allow_url_fopen') && !function_exists('curl_init')) { return null; } // World Bank: total population, world aggregate, most recent value $url = 'https://api.worldbank.org/v2/country/WLD/indicator/SP.POP.TOTL?format=json&per_page=5&mrv=5'; $raw = nst_http_get_limited($url, 6); if ($raw === null || $raw === '') return null; $j = json_decode($raw, true); if (!is_array($j) || !isset($j[1]) || !is_array($j[1])) return null; foreach ($j[1] as $row) { if (!is_array($row)) continue; $v = $row['value'] ?? null; if ($v === null || $v === '') continue; $n = (int)$v; if ($n >= 1000000000 && $n <= 50000000000) { return [ 'pop' => $n, 'source' => 'worldbank:SP.POP.TOTL', 'ts' => time(), ]; } } return null; } function nst_http_get_limited(string $url, int $timeoutSec): ?string { $host = parse_url($url, PHP_URL_HOST); if (!is_string($host) || !preg_match('/^(api\.worldbank\.org)$/i', $host)) { return null; // allowlist only - no open SSRF } if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch === false) return null; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_CONNECTTIMEOUT => $timeoutSec, CURLOPT_TIMEOUT => $timeoutSec, CURLOPT_USERAGENT => 'nosignup.trade-pop/1.0', CURLOPT_SSL_VERIFYPEER => true, ]); $body = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body === false || $code >= 400) return null; return (string)$body; } $ctx = stream_context_create([ 'http' => [ 'timeout' => $timeoutSec, 'header' => "User-Agent: nosignup.trade-pop/1.0\r\n", ], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], ]); $body = @file_get_contents($url, false, $ctx); return $body === false ? null : (string)$body; } /** * Optimum liquid treasury micros = fixed KING faucet start (24_000_000 NSU). * $pop retained for API compatibility / metadata; not used for supply scale. */ function nst_optimum_treasury_micros(int $pop = 0): string { $m = parse_amt((string)(int)NST_KING_FAUCET_START_NSU); if ($m !== null && dec_ok($m) && dec_cmp($m, '0') > 0) { return $m; } return NST_GENESIS_TREASURY; } /* ═══════════════════════════════════════════════════════════════════════════ * MONETARY SPINE — supply, emission, and the single gate every mint passes. * Design: NSU-DNA/08-BRIDGE-SPEC.md §7. Audit: NSU-DNA/09-EXPLOIT-AUDIT.md. * ═══════════════════════════════════════════════════════════════════════════ */ /** * THE canonical list of row types that create money. One definition, used * everywhere. Three separate places used to spell this out by hand — balances(), * the integer validator, and the conservation replay — so adding a mint type * meant remembering all three. Missing one does not fail loudly: it desyncs the * conservation audit and trips the E2 freeze, halting the whole economy. * * 'treasury_reanchor' STAYS in this list even though nothing creates one any * more. Chains already carry those rows; dropping the type would make replay * under-count historical issuance and freeze every live empire on upgrade. */ function nst_mint_types(): array { return ['genesis', 'treasury_reanchor', 'emission']; } function nst_is_mint_type(string $t): bool { return in_array($t, nst_mint_types(), true); } /** * How much money exists, REPLAYED FROM THE CHAIN — never stored. * * data/economy.json is destroyed and rebuilt at every yearly roll. A counter * kept there would read zero each new year and the empire would re-mint the * entire cap annually, forever, with the ceiling check agreeing because it too * would believe nothing had been minted. No attacker required; a page load * triggers the roll. See §7.8. balances() already works this way; so does this. * * @param array|null $rows chain rows, or null to read them */ function nst_minted_to_date(?array $rows = null): string { $rows = $rows ?? read_chain(); $sum = '0'; foreach ($rows as $r) { if (!is_array($r) || !nst_is_mint_type((string)($r['type'] ?? ''))) { continue; } foreach ($r['outputs'] ?? [] as $o) { if (!is_array($o)) continue; $m = (string)($o['amount'] ?? '0'); if (nst_int_micros_ok($m) && dec_cmp($m, '0') > 0) { $sum = dec_add($sum, $m); } } } return $sum; } /** Headroom under the ceiling. Zero means the tap is closed for good. */ function nst_supply_remaining(?array $rows = null): string { $rem = dec_sub(NST_MAX_SUPPLY, nst_minted_to_date($rows)); return dec_cmp($rem, '0') > 0 ? $rem : '0'; } /** * The day's emission: a fixed fraction of what is still unminted. * * E = (NST_MAX_SUPPLY - minted_to_date) * NST_EMISSION_DAILY_RATE_PPM / 1e6 * * Self-tapering, never exhausts, needs no schedule table, and is auditable from * two numbers anyone can read off the chain. Early participation is worth more * than late — the bootstrapping incentive — without a cliff at the end. */ function nst_emission_today(?array $rows = null): string { $rem = nst_supply_remaining($rows); if (dec_cmp($rem, '0') <= 0) return '0'; /* Multiply BEFORE dividing: these are arbitrary-precision strings, so the * big intermediate is free, and dividing first would round small remainders * to zero and silently close the tap early. */ $e = dec_div_small(dec_mul_small($rem, (int)NST_EMISSION_DAILY_RATE_PPM), 1000000); return dec_cmp($e, '0') > 0 ? $e : '0'; } /** * THE CHOKE POINT. Every row that creates money goes through here. * * Before this existed, NST_MAX_SUPPLY appeared only in display code — the * charter promised a "hard ceiling on all issuance" and nothing enforced it. * A mint that would breach the cap is CLAMPED to the remaining headroom, never * silently allowed and never partially written. * * @param string $type must be in nst_mint_types() * @param array $outputs * @param array $extra additional row fields (reason, ts, notes) * @return array{ok:bool,minted:string,clamped:bool,err?:string} */ function nst_mint_guarded(string $type, array $outputs, array $extra = []): array { if (!nst_is_mint_type($type)) { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'not a mint type: ' . $type]; } /* CHECK AND WRITE MUST BE ATOMIC. * append_row() locks only its own write. Reading the supply, deciding there * is headroom, and then appending is three steps — two concurrent requests * can both read the same total, both see room, and both append, putting * issuance over the ceiling. A cap that holds only when nobody is looking * twice at once is not a cap. This lock spans the whole decision. */ $lockPath = $GLOBALS['DATA'] . DIRECTORY_SEPARATOR . 'mint.lock'; $lock = @fopen($lockPath, 'cb'); if ($lock === false) { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'cannot open mint lock']; } if (!flock($lock, LOCK_EX)) { fclose($lock); return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'Mint is busy — try again in a moment']; } try { return nst_mint_guarded_locked($type, $outputs, $extra); } finally { flock($lock, LOCK_UN); fclose($lock); } } /** Inner mint. NEVER call directly — nst_mint_guarded() holds the lock. */ function nst_mint_guarded_locked(string $type, array $outputs, array $extra = []): array { $rows = read_chain(); /* The mint lock must protect uniqueness as well as the ceiling. A caller's * pre-lock "already emitted" read can race another process and otherwise * mint the same UTC day twice while both rows remain under the cap. */ if ($type === 'emission') { $day = (string)($extra['day'] ?? ''); if (preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/D', $day) !== 1) { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'invalid emission day']; } foreach ($rows as $existing) { if (($existing['type'] ?? '') === 'emission' && ($existing['day'] ?? '') === $day) { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'already_emitted_today']; } } } $remaining = nst_supply_remaining($rows); $want = '0'; foreach ($outputs as $o) { /* NEVER MINT TO AN EMPTY ADDRESS. * nst_conservation_replay() counts every mint leg toward expected * issuance, but balances() skips a leg with no address - so one empty * addr makes observed < expected and the E2 freeze halts the whole * economy. The coins would also be unspendable, because no seed derives * the empty address. Refuse the mint instead: a caller that cannot name * the payee is not ready to pay. */ $a = (string)($o['addr'] ?? ''); if ($a === '') { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'mint output has no address']; } $m = (string)($o['amount'] ?? '0'); if (!nst_int_micros_ok($m) || dec_cmp($m, '0') <= 0) { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'bad output amount']; } $want = dec_add($want, $m); } if (dec_cmp($want, '0') <= 0) { return ['ok' => false, 'minted' => '0', 'clamped' => false, 'err' => 'nothing to mint']; } $clamped = false; if (dec_cmp($want, $remaining) > 0) { if (dec_cmp($remaining, '0') <= 0) { return ['ok' => false, 'minted' => '0', 'clamped' => true, 'err' => 'ceiling reached - no supply remains']; } /* Fill legs in order until the headroom is gone, truncating the one that * straddles the ceiling and dropping any after it. This happens at most * once in the empire's life — the final partial day — so exact * proportions matter less than never writing a row that breaches. */ $scaled = []; $left = $remaining; foreach ($outputs as $o) { if (dec_cmp($left, '0') <= 0) break; $m = (string)$o['amount']; $part = (dec_cmp($m, $left) > 0) ? $left : $m; $scaled[] = ['addr' => (string)$o['addr'], 'amount' => $part]; $left = dec_sub($left, $part); } $outputs = $scaled; $want = $remaining; $clamped = true; } $row = array_merge($extra, [ 'type' => $type, 'ver' => 2, 'ts' => $extra['ts'] ?? time(), 'outputs' => array_values($outputs), 'minted_before' => nst_minted_to_date($rows), 'ceiling' => NST_MAX_SUPPLY, 'clamped' => $clamped, ]); if (!append_row($row)) { return ['ok' => false, 'minted' => '0', 'clamped' => $clamped, 'err' => 'mint commit failed']; } return ['ok' => true, 'minted' => $want, 'clamped' => $clamped]; } /** * RETIRED 2026-08-03 — the yearly treasury re-anchor. * * It topped the KING treasury back up to a fixed target at every wipe, which * meant whatever the King spent during the year came back as newly minted coin: * he kept the goods AND got the money back, every year, unbounded. See * 09-EXPLOIT-AUDIT.md C-1. Coins now enter only through nst_emission_today(), * and the King is paid a declared share of that emission like everyone else. * * Kept as a no-op so old call sites report cleanly instead of fataling. It must * never mint again. * * @return array{pop:int,source:string,target:string,before:string,after:string,action:string} */ function nst_treasury_reanchor_to_population(string $reason): array { return [ 'pop' => 0, 'source' => 'retired', 'target' => '0', 'before' => '0', 'after' => '0', 'action' => 'retired_no_mint', 'note' => 'treasury re-anchor retired: coins enter only via daily emission (spec 7)', ]; } /** * Run today's emission, once per UTC day, idempotently. * * Called on ordinary page loads. The "have we already run today" test reads the * CHAIN, not economy.json — economy.json is destroyed every year, so a marker * kept there would let the empire re-mint on the first load of each new epoch. * * Splits the day's coins three ways. The KING share is the remainder, so the * three parts always sum to exactly the emission: no rounding dust is minted * into nobody's wallet, and no part can silently reach zero. * * @return array{ran:bool,day:string,minted:string,reason?:string} */ /** * The ten crop lord wallets, as written by genesis into data/crops.json. * * Trade owns the only chain, so trade is the only thing that can pay anybody - * but each crop's panel seed is minted into that crop's own vault on its own * host, so trade has no other way to learn the nine sibling addresses. Genesis * is the single moment where all ten exist in one process, so genesis writes * them down. Addresses only; a seed in this file would be the whole empire. * * Returns [] when the registry is absent, which callers must treat as "this * empire cannot name its crops yet" rather than as an error. */ /** Parse and sanitise a crops list from any source (file or chain row). */ function nst_crop_list_clean($raw): array { $out = []; if (!is_array($raw)) return $out; $seen = []; foreach ($raw as $c) { if (!is_array($c)) continue; $addr = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($c['addr'] ?? '')) ?? ''); /* A crop address is a sha256 like every other wallet. Anything else is a * malformed registry, and paying emission to a malformed address burns * it as surely as paying a pool nobody holds. */ if (strlen($addr) !== 64) continue; if (isset($seen[$addr])) continue; // never pay one wallet two shares $seen[$addr] = true; $out[] = ['tld' => (string)($c['tld'] ?? ''), 'addr' => $addr]; } return $out; } /** The crop list as anchored on the chain, or [] if never anchored. */ function nst_crop_anchor(?array $rows = null): array { $rows = $rows ?? read_chain(); $found = []; foreach ($rows as $r) { if (is_array($r) && ($r['type'] ?? '') === 'crop_registry') { $found = nst_crop_list_clean($r['crops'] ?? null); // last one wins } } return $found; } /** * ANCHOR THE REGISTRY ON THE LEDGER, ONCE. * * data/crops.json decides who receives ~95% of every coin ever minted, and it is * a plain file the King can rewrite at any time to ten addresses they control. * Nothing would notice. That makes the ten-way split a configuration rather than * a rule. * * So the first time the empire can name its crops, it writes that list into the * append-only chain. From then on the CHAIN is authoritative and the file is * only a cache. Later edits to crops.json change nothing and are reported as a * mismatch. * * This is trust-on-first-use, and it is worth naming as such rather than * overselling it: whoever runs genesis chooses the ten addresses. What it buys * is that the choice is made ONCE, in public, on a ledger anyone replaying the * chain can read - instead of being silently re-choosable every day forever. * A King who wants to redirect emission now has to do it where it is visible. * * Not a mint, so conservation is untouched: no output legs, no money moves. */ function nst_crop_registry_anchor(): array { $rows = read_chain(); if (nst_crop_anchor($rows)) { return ['anchored' => false, 'reason' => 'already_anchored']; } $p = $GLOBALS['DATA'] . DIRECTORY_SEPARATOR . 'crops.json'; if (!is_file($p)) return ['anchored' => false, 'reason' => 'no_registry_file']; $j = json_decode((string)@file_get_contents($p), true); $crops = nst_crop_list_clean(is_array($j) ? ($j['crops'] ?? null) : null); if (!$crops) return ['anchored' => false, 'reason' => 'registry_empty_or_malformed']; $res = nst_with_chain_lock(function (array $chain, callable $append) use ($crops) { foreach ($chain as $r) { // re-check under the lock if (is_array($r) && ($r['type'] ?? '') === 'crop_registry') { return ['anchored' => false, 'reason' => 'already_anchored']; } } $row = [ 'type' => 'crop_registry', 'ver' => 1, 'crops' => $crops, 'count' => count($crops), 'ts' => time(), 'note' => 'Crop lord wallets that receive the daily emission. Anchored once, ' . 'trust-on-first-use; the chain is authoritative from here and data/crops.json ' . 'is only a cache. Not a mint - no money moves on this row.', ]; if (!$append($row)) return ['anchored' => false, 'reason' => 'storage']; return ['anchored' => true, 'count' => count($crops)]; }); return is_array($res) ? $res : ['anchored' => false, 'reason' => 'lock']; } /** True when data/crops.json disagrees with what the chain anchored. */ function nst_crop_registry_mismatch(): bool { $anchor = nst_crop_anchor(); if (!$anchor) return false; $p = $GLOBALS['DATA'] . DIRECTORY_SEPARATOR . 'crops.json'; $j = is_file($p) ? json_decode((string)@file_get_contents($p), true) : null; $file = nst_crop_list_clean(is_array($j) ? ($j['crops'] ?? null) : null); return array_column($file, 'addr') !== array_column($anchor, 'addr'); } function nst_crop_addrs(): array { static $cache = null; if ($cache !== null) return $cache; /* THE CHAIN WINS. Once anchored, editing crops.json cannot redirect a single * coin - it only produces a visible mismatch. The file is a cache used to * bootstrap the anchor and nothing more. */ $anchor = nst_crop_anchor(); if ($anchor) { $cache = $anchor; return $cache; } $p = $GLOBALS['DATA'] . DIRECTORY_SEPARATOR . 'crops.json'; if (!is_file($p)) { $cache = []; return $cache; } $j = json_decode((string)@file_get_contents($p), true); $cache = nst_crop_list_clean(is_array($j) ? ($j['crops'] ?? null) : null); return $cache; } function nst_emission_run_daily(): array { $day = gmdate('Y-m-d'); $rows = read_chain(); /* Idempotence from the ledger itself. */ foreach ($rows as $r) { if (is_array($r) && ($r['type'] ?? '') === 'emission' && ($r['day'] ?? '') === $day) { return ['ran' => false, 'day' => $day, 'minted' => '0', 'reason' => 'already_emitted_today']; } } $total = nst_emission_today($rows); if (dec_cmp($total, '0') <= 0) { return ['ran' => false, 'day' => $day, 'minted' => '0', 'reason' => 'ceiling_reached']; } $kingAddr = nst_treasury_addr_safe(); if ($kingAddr === '') { return ['ran' => false, 'day' => $day, 'minted' => '0', 'reason' => 'no_treasury_addr']; } /* Ten crops, one equal share each, King's cut off the top of every share. * * WAIT FOR THE REGISTRY RATHER THAN MISALLOCATING. * * This used to fall back to paying the whole day to the King when crops.json * was missing, on the reasoning that stalling issuance is worse than a * recoverable concentration. That reasoning was wrong because the fallback * was not rare - it fired on EVERY virgin deploy, which made it the primary * path rather than the exception. * * The cause is an ordering the fallback quietly hid: trade self-keys and runs * its first emission on the first page load, while genesis - which is what * writes the registry - runs afterwards. So day one always paid one address * and never split. Observed live on 2026-08-04: 10,000 NSU to the King, all * ten crop panels at zero. * * Skipping mints nothing and loses nothing. Emission is idempotent per UTC * day and driven by page loads, so the moment the registry lands the same * day's emission runs correctly. A day that pays the wrong wallets, by * contrast, cannot be un-minted. */ $crops = nst_crop_addrs(); if (!$crops) { return ['ran' => false, 'day' => $day, 'minted' => '0', 'reason' => 'no_crop_registry', 'note' => 'data/crops.json absent - genesis has not registered the crops yet. ' . 'Nothing is minted until the empire can name who it is paying. Run Deploy.bat.']; } $outputs = []; $perCrop = '0'; $kingTaxTotal = '0'; $n = count($crops); $perCrop = dec_div_small($total, $n); $paid = '0'; foreach ($crops as $i => $c) { $addr = (string)($c['addr'] ?? ''); if ($addr === '') continue; /* Last crop absorbs the rounding dust so the legs sum to EXACTLY the * emission. Dust that belongs to nobody is minted coins with no * owner, which conservation would then have to explain away. */ $share = ($i === $n - 1) ? dec_sub($total, $paid) : $perCrop; if (dec_cmp($share, '0') <= 0) continue; $paid = dec_add($paid, $share); $split = nst_fee_split($share); // same rule as every other lord receipt if (dec_cmp($split['lord'], '0') > 0) { $outputs[] = ['addr' => $addr, 'amount' => $split['lord']]; } if (dec_cmp($split['king'], '0') > 0) { $kingTaxTotal = dec_add($kingTaxTotal, $split['king']); } } if (dec_cmp($kingTaxTotal, '0') > 0) { $outputs[] = ['addr' => $kingAddr, 'amount' => $kingTaxTotal]; } if (!$outputs) { return ['ran' => false, 'day' => $day, 'minted' => '0', 'reason' => 'nothing_to_split']; } $res = nst_mint_guarded('emission', $outputs, [ 'day' => $day, 'reason' => 'daily_emission', 'rate_ppm' => NST_EMISSION_DAILY_RATE_PPM, 'crops_paid' => count($crops), 'per_crop_micros' => $perCrop, 'king_tax_ppm' => NST_KING_TAX_PPM, 'king_tax_micros' => $kingTaxTotal, 'split_rule' => 'equal share per crop, King tax off the top of each share', 'note' => 'daily emission: the only path by which NSU comes into existence', ]); if (empty($res['ok'])) { return ['ran' => false, 'day' => $day, 'minted' => '0', 'reason' => (string)($res['err'] ?? 'mint_refused')]; } return ['ran' => true, 'day' => $day, 'minted' => (string)$res['minted']]; } /** * Split a SERVICE FEE between the lord who earned it and the King's tax. * * This CIRCULATES money — it never creates any. Duplicating a fee into the * King's vault instead would be an infinite mint: pay a lord you control, get a * mirrored copy, repeat at zero cost. A tax makes self-dealing mildly negative * rather than infinitely profitable, which is the whole point. * * ONLY for fees paid TO the empire — ad slots, amplification, rent. NEVER for a * trade between two users. Taking a slice of two strangers settling their own * terms is a gatekeeper's cut, which the ethos refuses outright. * * @param string $feeMicros total the payer hands over * @return array{lord:string,king:string} the two legs, summing to exactly $feeMicros */ function nst_fee_split(string $feeMicros): array { if (!nst_int_micros_ok($feeMicros) || dec_cmp($feeMicros, '0') <= 0) { return ['lord' => '0', 'king' => '0']; } $king = dec_div_small(dec_mul_small($feeMicros, (int)NST_KING_TAX_PPM), 1000000); /* THE TAX MAY NEVER ROUND TO ZERO. * * Integer division floors, so at 5% any receipt below 20 micros produced a * King cut of exactly 0 - and revenue split into 19-micro slices was * therefore tax-free. Small, but it is a leak with no floor: the cost of * exploiting it is only the number of receipts you are willing to write. * * Charging a minimum of one micro closes it and makes dust-splitting * strictly WORSE than paying honestly, which is the property worth having. * A 1-micro receipt is taxed at 100% (0.000001 NSU, and the lord keeps * nothing), 19 micros at ~5.3%, and anything of real size at exactly 5%. So * the incentive runs the right way at every scale. * * dec_sub below still gives the lord the remainder, so the two legs always * reconstruct the fee exactly - dust that belongs to nobody is a * conservation violation waiting to happen. */ if (dec_cmp($king, '0') <= 0) { $king = '1'; } $lord = dec_sub($feeMicros, $king); if (dec_cmp($lord, '0') < 0) { $lord = '0'; $king = $feeMicros; } return ['lord' => $lord, 'king' => $king]; } /** KING treasury address, or '' when the empire is not keyed yet. */ function nst_treasury_addr_safe(): string { $f = $GLOBALS['TFILE'] ?? ''; if (!$f || !is_file($f)) return ''; $sec = trim((string)@file_get_contents($f)); if ($sec === '') return ''; return addr_from_seed($sec); } /* The original re-anchor body (which minted a refill every wipe) has been * DELETED, not merely unhooked. Dead code that mints is a loaded gun: an * autonomous pass looking for a faucet would find it and wire it back up. * History for it lives in NSU-DNA/09-EXPLOIT-AUDIT.md C-1. */ /** * Generate a 12-word site-local seed (same product surface as user wallets). * Used for: (KING) treasury/master faucet at genesis; (LORD) operator panel seed on rent. * KING seed never goes to renters. LORD seed never unlocks faucet/mint. */ function nst_generate_site_seed(): string { // 12-word site seed (not BIP39). Product surface matches user wallets. // Word pool is small + high entropy bytes — site-local only. static $wl = [ 'able','acid','aged','also','aqua','arch','area','army','atom','aunt','auto','avoid', 'axis','baby','band','bank','bare','barn','base','bean','bear','belt','bike','bind', 'bird','bite','blue','boat','body','bold','bolt','bone','book','boot','born','bowl', 'brass','brave','bread','brick','brief','bring','broad','broke','brown','brush','build','bulk', 'burn','burst','bush','busy','cable','cage','cake','calm','camp','cane','cape','card', 'care','cart','case','cash','cast','cave','cell','cent','chat','chef','chin','chip', 'city','clap','clay','clip','club','coal','coat','code','coil','coin','cold','come', 'cook','cool','cope','copy','cord','core','corn','cost','cove','crab','crew','crop', 'crow','cube','cult','curb','cure','curl','dark','dart','dash','data','dawn','deal', 'dear','deck','deep','deer','desk','dial','dice','diet','dine','dirt','disc','dock', 'dome','done','door','dose','down','draw','drip','drop','drum','dual','duck','dune', 'dusk','dust','duty','each','earn','east','easy','echo','edge','edit','else','emit', 'epic','even','ever','evil','exit','face','fact','fade','fail','fair','fall','fame', 'farm','fast','fate','fear','feed','feel','fern','file','fill','film','find','fine', 'fire','firm','fish','flag','flat','flee','flip','flow','foam','foil','fold','font', 'food','fool','foot','ford','fork','form','fort','foul','four','free','frog','from', 'fuel','full','fund','fuse','gain','game','gate','gear','gene','gift','girl','give', 'glad','glow','glue','goal','goat','gold','golf','good','grab','grad','gram','gray', 'grid','grim','grin','grip','grow','gulf','guru','hail','hair','half','hall','hand', 'hang','hard','harm','harp','hate','have','hawk','haze','head','heal','heap','heat', 'heed','heel','held','help','herb','here','hero','hide','high','hill','hint','hire', 'hold','hole','home','hood','hook','hope','horn','host','hour','huge','hull','hung', 'hunt','hurt','icon','idea','idle','inch','info','into','iron','item','jade','jail', 'jazz','join','joke','jump','june','jury','just','keen','keep','kept','kick','kind', 'king','kite','knee','knew','knit','knot','know','lace','lack','lady','lake','lamp', 'land','lane','last','late','lava','lawn','lead','leaf','lean','left','lend','lens', ]; $n = count($wl); $bytes = random_bytes(12); $out = []; for ($i = 0; $i < 12; $i++) { $out[] = $wl[ord($bytes[$i]) % $n]; } return implode(' ', $out); } /** * Shared vault note writer — file body is caller-owned; never mixes KING/SITE/ADMIN secrets. * Each role builds its own $body; this only ensures dir + 0600 write. */ function nst_vault_write_note(string $filename, string $body): void { nst_vault_write_private_file($filename, $body); } /** * Vault note for KING: data/treasury.secret only. Never to renters/lords. * E3: distinct from trade panel site.seed (11-wallet: KING is the 11th, only minter). */ function nst_vault_king_seed_note(string $seed): void { $body = "NOSIGNUP.TRADE KING / MASTER FAUCET SEED (OWNER ONLY — HIDDEN VAULT)\n" . "Role: empire KING vault wallet. ONLY minter for THIS trade HQ.\n" . "File: data/treasury.secret · Unlocks faucet/mint (require_faucet_auth).\n" . "Also unlocks /controlpanel as owner override. Lords NEVER receive this.\n" . "Genesis / re-anchor liquid = " . NST_KING_FAUCET_START_NSU . " NSU. NO RECOVERY.\n" . "11-wallet model: this is the 11th seed (hidden KING), not a site panel.\n" . "Generated: " . gmdate('c') . "\n\n" . trim($seed) . "\n"; nst_vault_write_note('KING_FAUCET_SEED.txt', $body); } /** @deprecated name kept: writes KING vault note (not panel). */ function nst_vault_site_seed_note(string $seed): void { nst_vault_king_seed_note($seed); } /** * Vault note for trade SITE PANEL wallet (one of 10 crop panels). Not a mint. */ function nst_vault_panel_seed_note(string $seed): void { $body = "NOSIGNUP.TRADE SITE PANEL WALLET SEED (THIS CROP ONLY)\n" . "Role: /controlpanel unlock + site operator wallet for trade.\n" . "File: data/site.seed · Does NOT mint. Lords never mint; KING alone mints.\n" . "After public rent pay: economy.operator_addr may become the LORD payer key.\n" . "Generated: " . gmdate('c') . "\n\n" . trim($seed) . "\n"; nst_vault_write_note('SITE_WALLET_SEED.txt', $body); } /** Read one published authority seed with the exact wallet grammar. */ function nst_seed_file_read_valid(string $path): string { if (is_link($path) || !is_file($path)) throw new RuntimeException('seed_file_invalid'); $raw = @file_get_contents($path); $seed = is_string($raw) ? norm_seed($raw) : ''; if (!preg_match('/\A[a-z]{1,24}(?: [a-z]{1,24}){11}\z/D', $seed)) { throw new RuntimeException('seed_file_invalid'); } return $seed; } /** Create a seed file once through a durable temp+rename publication. */ function nst_seed_file_create_once(string $path, callable $vaultNote): string { if (is_link($path) || (file_exists($path) && !is_file($path))) { throw new RuntimeException('seed_file_invalid'); } if (is_file($path)) { $seed = nst_seed_file_read_valid($path); @chmod($path, 0600); $vaultNote($seed); return $seed; } foreach ((array)(glob($path . '.init.*.tmp') ?: []) as $stale) { if (!preg_match('/\A' . preg_quote(basename($path), '/') . '\.init\.[0-9a-f]{24}\.tmp\z/D', basename($stale))) continue; $mtime = @filemtime($stale); if (is_file($stale) && !is_link($stale) && is_int($mtime) && $mtime < time() - 600) @unlink($stale); } $seed = nst_generate_site_seed(); $bytes = $seed . "\n"; $tmp = $path . '.init.' . bin2hex(random_bytes(12)) . '.tmp'; $fh = @fopen($tmp, 'x+b'); if (!is_resource($fh)) throw new RuntimeException('seed_file_unavailable'); $published = false; try { @chmod($tmp, 0600); if (!nst_stream_write_all($fh, $bytes) || !nst_stream_flush_durable($fh)) { throw new RuntimeException('seed_file_unavailable'); } @fclose($fh); $fh = null; if (file_exists($path) || is_link($path)) { $winner = nst_seed_file_read_valid($path); @chmod($path, 0600); $vaultNote($winner); return $winner; } if (!@rename($tmp, $path)) { /* Another process may have won between the existence check and * rename. Adopt only its fully valid published seed. */ if (is_file($path) && !is_link($path)) { $winner = nst_seed_file_read_valid($path); @chmod($path, 0600); $vaultNote($winner); return $winner; } throw new RuntimeException('seed_file_unavailable'); } $tmp = ''; @chmod($path, 0600); $saved = @file_get_contents($path); if (!is_string($saved) || !hash_equals($bytes, $saved)) { throw new RuntimeException('seed_file_invalid'); } $published = true; } finally { if (is_resource($fh)) @fclose($fh); if (!$published && $tmp !== '' && is_file($tmp) && !is_link($tmp)) @unlink($tmp); } $vaultNote($seed); return $seed; } /** * Ensure trade panel site.seed exists (distinct from KING treasury.secret on fresh genesis). * Do not invent a second key on pre-E3 installs that already genesi'd without site.seed — * only call from the fresh-genesis path. */ function ensure_panel_site_seed_only(): string { global $SITE_SEED_FILE; boot_data(); return nst_seed_file_create_once($SITE_SEED_FILE, 'nst_vault_panel_seed_note'); } /** * On fresh genesis: operator.addr = panel site.seed addr (≠ KING treasury). * File is public-ish addr only (not secret). Economy.operator_addr may diverge for rent later. * Legacy (no site.seed): falls back to treasury addr so pre-E3 installs keep working. */ function nst_ensure_operator_addr(?string $addr = null): string { global $OP_ADDR_FILE, $SITE_SEED_FILE, $TFILE; boot_data(); if ($addr === null || $addr === '') { if (is_file($OP_ADDR_FILE)) { $have = strtolower(trim((string)@file_get_contents($OP_ADDR_FILE))); if (preg_match('/^[a-f0-9]{64}$/', $have)) { return $have; } } // Prefer panel site.seed (E3 distinct panel wallet); else KING treasury (legacy conflation). if (isset($SITE_SEED_FILE) && is_file($SITE_SEED_FILE)) { $psec = trim((string)@file_get_contents($SITE_SEED_FILE)); if ($psec !== '') { $addr = addr_from_seed($psec); } } if (($addr === null || $addr === '') && is_file($TFILE)) { $sec = trim((string)@file_get_contents($TFILE)); if ($sec !== '') { $addr = addr_from_seed($sec); } } } if ($addr === null || $addr === '' || !preg_match('/^[a-f0-9]{64}$/', $addr)) { return ''; } $addr = strtolower($addr); if (!is_file($OP_ADDR_FILE) || trim((string)@file_get_contents($OP_ADDR_FILE)) === '') { file_put_contents($OP_ADDR_FILE, $addr . "\n", LOCK_EX); @chmod($OP_ADDR_FILE, 0644); } return $addr; } /** * Panel unlock addresses (E3): * - KING treasury seed (owner override; still not a lord mint path) * - trade panel site.seed (site admin door; no mint) * - economy.operator_addr (LORD after rent pay-bind) * Never ship treasury.secret to renters. * * @return list */ function panel_unlock_addrs(): array { $out = []; global $TFILE, $SITE_SEED_FILE; if (is_file($TFILE)) { $sec = trim((string)@file_get_contents($TFILE)); if ($sec !== '') { $out[] = addr_from_seed($sec); } } if (isset($SITE_SEED_FILE) && is_file($SITE_SEED_FILE)) { $psec = trim((string)@file_get_contents($SITE_SEED_FILE)); if ($psec !== '') { $pa = addr_from_seed($psec); if (!in_array($pa, $out, true)) { $out[] = $pa; } } } $e = load_economy(); $op = strtolower(trim((string)($e['operator_addr'] ?? ''))); if (preg_match('/^[a-f0-9]{64}$/', $op) && !in_array($op, $out, true)) { // rent path: LORD payer key unlocks panel without treasury secret $out[] = $op; } return $out; } /** True if seed derives KING, site panel, or economy.operator_addr (LORD). */ function panel_seed_ok(string $seed): bool { $seed = norm_seed($seed); if ($seed === '') { return false; } $addr = addr_from_seed($seed); foreach (panel_unlock_addrs() as $a) { if (hash_equals($a, $addr)) { return true; } } return false; } /** True if seed derives the treasury master faucet (KING) only — not LORD operator. */ function treasury_seed_ok(string $seed): bool { $seed = norm_seed($seed); if ($seed === '') { return false; } global $TFILE; if (!is_file($TFILE)) { return false; } $sec = trim((string)@file_get_contents($TFILE)); if ($sec === '') { return false; } return hash_equals(addr_from_seed($sec), addr_from_seed($seed)); } /** * Faucet / mint authority (KING): treasury seed ONLY. * LORD operator seed never unlocks faucet, treasury reveal, free-form pay, rent claim, * or profit extract. Leftover admin.pass.hash is unlinked; require_admin is site seed only * and MUST NOT mint — a renter must never escalate to faucet (isolation hole closed 2026-07-15). */ function require_faucet_auth(): void { $seed = (string)($_POST['seed'] ?? ''); if ($seed !== '' && treasury_seed_ok($seed)) { return; } api_out(['ok' => false, 'err' => 'faucet auth'], 401); } /** * DEAD — former market-value LORD rent GRANT compute (treasury → LORD). * Kept only so old call sites / tests that reference the symbol do not fatals until full prune. * Product rent must NOT call this for live settlement (admin_rent_claim is 410). * @param string $treasuryMicros remaining treasury balance (micros) * @param string $lastProfitMicros prior epoch profit pile at wipe (micros), or '0' * @return array{ok:bool,micros?:string,credit_nsu?:string,parts?:array,err?:string} */ function nst_lord_rent_credit_compute(string $treasuryMicros, string $lastProfitMicros = '0'): array { $treasuryMicros = dec_norm($treasuryMicros); $lastProfitMicros = dec_norm($lastProfitMicros); if (!dec_ok($treasuryMicros) || !dec_ok($lastProfitMicros)) { return ['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)']; } // percent of remaining faucet $pctPart = '0'; if (LORD_RENT_PCT_BPS > 0 && dec_cmp($treasuryMicros, '0') > 0) { $pctPart = dec_div_small(dec_mul_small($treasuryMicros, (int)LORD_RENT_PCT_BPS), 10000); } $flatBase = parse_amt((string)(int)LORD_RENT_FLAT_BASE_NSU); if ($flatBase === null) { return ['ok' => false, 'err' => 'flat base invalid']; } $perfPart = '0'; if (LORD_RENT_FLAT_PERF_BPS > 0 && dec_cmp($lastProfitMicros, '0') > 0) { $perfPart = dec_div_small(dec_mul_small($lastProfitMicros, (int)LORD_RENT_FLAT_PERF_BPS), 10000); $cap = parse_amt((string)(int)LORD_RENT_FLAT_PERF_CAP_NSU); if ($cap !== null && dec_cmp($perfPart, $cap) > 0) { $perfPart = $cap; } } $total = dec_add(dec_add($pctPart, $flatBase), $perfPart); $minM = parse_amt((string)(int)LORD_RENT_MIN_NSU); $maxM = parse_amt((string)(int)LORD_RENT_MAX_NSU); if ($minM !== null && dec_cmp($total, $minM) < 0) { $total = $minM; } if ($maxM !== null && dec_cmp($total, $maxM) > 0) { $total = $maxM; } if (dec_cmp($total, '1') < 0) { return ['ok' => false, 'err' => 'credit too small']; } // whole NSU display (floor micros / 1e6) $creditNsu = dec_div_small($total, (int)str_pad('1', NST_DECIMALS + 1, '0')); return [ 'ok' => true, 'micros' => $total, 'credit_nsu' => $creditNsu, 'parts' => [ 'pct_bps' => LORD_RENT_PCT_BPS, 'pct_micros' => $pctPart, 'pct_fmt' => fmt_amt($pctPart), 'flat_base_nsu' => LORD_RENT_FLAT_BASE_NSU, 'flat_base_fmt' => fmt_amt($flatBase), 'flat_perf_bps' => LORD_RENT_FLAT_PERF_BPS, 'flat_perf_micros' => $perfPart, 'flat_perf_fmt' => fmt_amt($perfPart), 'last_profit_fmt' => fmt_amt($lastProfitMicros), 'treasury_fmt' => fmt_amt($treasuryMicros), 'min_nsu' => LORD_RENT_MIN_NSU, 'max_nsu' => LORD_RENT_MAX_NSU, 'legacy_flat_placeholder' => LORD_RENT_CREDIT_LEGACY, 'faucet_start_nsu_narrative' => NST_KING_FAUCET_START_NSU, ], ]; } /** @deprecated prefer nst_lord_rent_credit_compute with live treasury */ function nst_lord_rent_credit_micros(): ?string { $c = nst_lord_rent_credit_compute('0', '0'); return !empty($c['ok']) ? (string)$c['micros'] : null; } /** Ensure KING treasury.secret exists without double-genesis. New installs get 12-word KING seed. */ function ensure_genesis_secret_only(): void { global $TFILE; boot_data(); nst_seed_file_create_once($TFILE, 'nst_vault_site_seed_note'); } function fill_amounts(array $rows): array { $filled = []; foreach ($rows as $r) { if (($r['type'] ?? '') === 'fill' && !empty($r['order_id'])) { $filled[$r['order_id']] = dec_add($filled[$r['order_id']] ?? '0', $r['amount'] ?? '0'); } } return $filled; } function open_orders(array $rows): array { $live = []; $cancelled = []; $filled = fill_amounts($rows); foreach ($rows as $r) { $t = $r['type'] ?? ''; if ($t === 'order') $live[$r['id']] = $r; if ($t === 'cancel' && !empty($r['order_id'])) $cancelled[$r['order_id']] = true; } $now = time(); $out = []; foreach ($live as $id => $o) { if (!empty($cancelled[$id])) continue; if (($o['expiry'] ?? 0) < $now) continue; $rem = dec_sub($o['amount'] ?? '0', $filled[$id] ?? '0'); if (dec_cmp($rem, '1') < 0) continue; $o['remaining'] = $rem; $o['remaining_fmt'] = fmt_amt($rem); $out[] = $o; } usort($out, function ($a, $b) { $c = dec_cmp($a['price'] ?? '0', $b['price'] ?? '0'); if (($a['side'] ?? '') === 'buy') $c = -$c; // buys high first if ($c !== 0) return $c; return ($a['ts'] ?? 0) <=> ($b['ts'] ?? 0); }); return $out; } /** NSU locked in open sell orders (pair starts with NSU) */ function reserved_nsu(array $rows, string $addr): string { $sum = '0'; foreach (open_orders($rows) as $o) { if (($o['addr'] ?? '') !== $addr) continue; if (($o['side'] ?? '') !== 'sell') continue; $pair = strtoupper($o['pair'] ?? ''); if (!str_starts_with($pair, 'NSU')) continue; $sum = dec_add($sum, $o['remaining'] ?? '0'); } return $sum; } function available_nsu(array $bal, array $rows, string $addr): string { $have = $bal[$addr] ?? '0'; $res = reserved_nsu($rows, $addr); $av = dec_sub($have, $res); return dec_cmp($av, '0') < 0 ? '0' : $av; } function rep_scores(array $rows): array { // latest edge per (rater,rated) $edges = []; foreach ($rows as $r) { if (($r['type'] ?? '') !== 'reputation') continue; $k = ($r['rater'] ?? '') . '>' . ($r['rated'] ?? ''); $prev = $edges[$k] ?? null; if (!$prev || ($r['ts'] ?? 0) >= ($prev['ts'] ?? 0)) $edges[$k] = $r; } $score = []; foreach ($edges as $e) { $rated = $e['rated'] ?? ''; $s = (int)($e['score'] ?? 0); if ($rated === '' || $s < -10 || $s > 10) continue; $score[$rated] = ($score[$rated] ?? 0) + $s; } return $score; } /* ---- meta / epochs / modifiers / admin (data/*.txt inaccessible via .htaccess) ---- */ function load_meta(): array { global $META; if (!is_file($META)) return []; $j = json_decode((string)file_get_contents($META), true); return is_array($j) ? $j : []; } function save_meta(array $m): void { global $META; file_put_contents($META, j($m), LOCK_EX); } /** Site-local economy: profit pile + ad bid stake (not empire-wide). */ function load_economy(): array { global $ECON; $def = [ 'profit_micros' => '0', 'ad_bid_micros' => '0', 'donate_k' => NST_DONATE_K, 'value_note' => '', 'bids' => [], 'donations' => [], ]; if (!is_file($ECON)) return $def; $j = json_decode((string)file_get_contents($ECON), true); if (!is_array($j)) return $def; return array_merge($def, $j); } function save_economy(array $e): void { global $ECON; file_put_contents($ECON, j($e), LOCK_EX); } function ads_dir(): string { global $ADS; if (!is_dir($ADS)) @mkdir($ADS, 0755, true); $ht = $ADS . DIRECTORY_SEPARATOR . '.htaccess'; // creatives served only via ?api=ad_img - deny direct if under web (belt) if (!is_file($ht)) @file_put_contents($ht, "Require all denied\nDeny from all\n"); return $ADS; } /** * Yearly reset wipe (product intent = Chinese New Year epoch boundary). * ONLY user NSU wallet balances survive (chain history). * Superstructure is burned so a leaked/brute-forced panel password can only * steal for the remainder of the epoch - not forever. * * CNY / epoch inferences (operator 2026-07-15, honesty 2026-08-15): * - Rent is an epoch lease: LORD operator seed + rented flag die at wipe. * - Re-anchor is retired (no-op). Coins enter only via daily emission. * - User balances survive (no seizure); open orders / ads / profit piles do not. * - Password product path (if any residual) is re-keyed; seed-panel is product door. * - Device-wallet assignment ledger + hmac are superstructure and burn. * * Wiped: profit pile, ad stakes/bids, ad creatives, donate credit log, rent lease, * device-wallet assignment tsv / lock / hmac.secret (cookies become invalid locators). * Not wiped: transfer ledger (balances). Real-chain vault seeds stay offline * (operator must move external funds; site does not auto-sweep chains). * Admin panel password: AUTOMATICALLY rotated on wipe so leaked passwords die * at year boundary; new cleartext lands in vault/ only. */ function economy_on_epoch_roll(int $fromN, int $toN): void { $prev = load_economy(); $oldProfit = (string)($prev['profit_micros'] ?? '0'); $oldAd = (string)($prev['ad_bid_micros'] ?? '0'); $oldBids = is_array($prev['bids'] ?? null) ? count($prev['bids']) : 0; $oldLordCredit = (string)($prev['lord_credit_micros'] ?? '0'); // burn site-local superstructure; end renter lease; stash last-year performance $e = [ 'profit_micros' => '0', 'ad_bid_micros' => '0', 'donate_k' => (string)($prev['donate_k'] ?? NST_DONATE_K), 'bids' => [], 'donations' => [], 'epoch_n' => $toN, 'rolled_from' => $fromN, 'roll_ts' => time(), 'wipe' => 'balances_only_survive', 'wipe_calendar' => 'chinese_new_year_epoch', // CNY: renter lease ends — new year needs new purchase + new LORD seed 'rented' => false, 'operator_addr' => '', 'rented_at' => 0, 'lord_credit_micros' => '0', // performance inputs for next rent grant formula 'last_epoch_profit_micros' => $oldProfit, 'last_epoch_ad_bid_micros' => $oldAd, 'last_epoch_lord_credit_micros' => $oldLordCredit, 'last_epoch_n' => $fromN, ]; save_economy($e); // clear public operator.addr file so panel falls back to KING treasury until re-rent global $OP_ADDR_FILE; if (isset($OP_ADDR_FILE) && is_file($OP_ADDR_FILE)) { @file_put_contents($OP_ADDR_FILE, '', LOCK_EX); } // delete ad PNGs under data/ads/ $dir = ads_dir(); foreach (glob($dir . DIRECTORY_SEPARATOR . '*.png') ?: [] as $f) { @unlink($f); } // cancel open orders so book does not survive as superstructure $ts = time(); $nCancel = 0; foreach (open_orders(read_chain()) as $o) { $oid = (string)($o['id'] ?? ''); $addr = (string)($o['addr'] ?? ''); if ($oid === '' || $addr === '') continue; append_row([ 'type' => 'cancel', 'ver' => 1, 'order_id' => $oid, 'addr' => $addr, 'ts' => $ts, 'sig' => 'year_wipe', 'auth' => 'epoch_roll', 'note' => 'yearly wipe - open orders do not survive', ]); $nCancel++; } // permanent early modifiers do not survive - only balances global $MODS_FILE; if (is_file($MODS_FILE)) { @file_put_contents($MODS_FILE, '{}', LOCK_EX); } // Wallet Inbox v0: sealed notices burn with superstructure (balances survive). nst_inbox_burn_all(); // leftover panel password dies (product door is site seed) $rotated = false; try { admin_pass_burn(); $rotated = true; @file_put_contents( vault_dir() . DIRECTORY_SEPARATOR . 'YEAR_WIPE.txt', "Year wipe epoch $fromN -> $toN at " . gmdate('c') . "\n" . "Leftover panel password unlinked (admin.pass.hash / admin.pass.txt / ADMIN_PASSWORD.txt).\n" . "Panel door is site seed only.\n" . "ONLY user NSU balances survived on the ledger.\n" . "Device-wallet assignment ledger burned; leftover cookies are dead locators.\n" . "Re-anchor retired; coins enter only via daily emission.\n", LOCK_EX ); } catch (Throwable $ex) { $rotated = false; } // Superstructure: assignment ledger is a convenience locator, not a surviving wallet. $deviceWalletBurned = nst_device_wallet_burn_store(); // Re-anchor is retired (no-op). Do not mint to make old comments true. Users keep coins. $anchor = ['action' => 'skipped', 'pop' => 0, 'source' => '', 'target' => '0', 'before' => '0', 'after' => '0']; try { $anchor = nst_treasury_reanchor_to_population('year_wipe_epoch_' . $fromN . '_to_' . $toN); } catch (Throwable $ex) { $anchor['action'] = 'error'; } // Book was wiped empty — re-seed loose discovery spread (no live orders to protect). $boot = ['ok' => false]; try { $boot = nst_bootstrap_spread_if_empty(); } catch (Throwable $ex) { $boot = ['ok' => false, 'err' => 'bootstrap_exception']; } append_row([ 'type' => 'economy_year_wipe', 'ver' => 2, 'ts' => $ts, 'from_epoch' => $fromN, 'to_epoch' => $toN, 'wiped_profit_fmt' => fmt_amt($oldProfit), 'wiped_ad_bid_fmt' => fmt_amt($oldAd), 'wiped_bids' => $oldBids, 'orders_cancelled' => $nCancel, 'admin_rotated' => $rotated, 'population' => $anchor['pop'] ?? 0, 'population_source' => $anchor['source'] ?? '', 'treasury_target_fmt' => isset($anchor['target']) ? fmt_amt((string)$anchor['target']) : null, 'treasury_reanchor' => $anchor['action'] ?? null, 'treasury_after_fmt' => isset($anchor['after']) ? fmt_amt((string)$anchor['after']) : null, 'bootstrap_spread' => !empty($boot['ok']) && empty($boot['skipped']), 'bootstrap_note' => $boot['reason'] ?? ($boot['amount_fmt'] ?? null), 'device_wallet_burned' => $deviceWalletBurned, 'siphon_note' => 'profit/ad/device-wallet superstructure burns; re-anchor retired; renters never mint; coins enter only via daily emission; user balances only survive', 'note' => 'yearly reset: ONLY wallet balances survive; superstructure wiped including device-wallet locator; admin re-keyed; re-anchor retired; bootstrap spread if book empty', ]); @file_put_contents( vault_dir() . DIRECTORY_SEPARATOR . 'YEAR_WIPE.txt', "Year wipe epoch $fromN -> $toN at " . gmdate('c') . "\n" . "Leftover panel password unlinked; panel door is site seed only.\n" . "Old panel passwords are dead.\n" . "ONLY user NSU balances survived on the ledger.\n" . "Device-wallet assignment ledger burned: " . ($deviceWalletBurned ? 'yes' : 'no') . "\n" . "Rent siphon: profit/ad piles wiped. Re-anchor retired. Coins enter only via daily emission (renters never mint).\n" . "CNY epoch: LORD rent lease ended; operator_addr cleared; next renter gets new seed + new grant.\n" . "Last-year profit stashed for next LORD rent flat-performance term: " . fmt_amt($oldProfit) . " NSU.\n" . "Treasury re-anchor call (retired no-op): action=" . ($anchor['action'] ?? '?') . " pop=" . ($anchor['pop'] ?? '?') . " source=" . ($anchor['source'] ?? '?') . " target_fmt=" . (isset($anchor['target']) ? fmt_amt((string)$anchor['target']) : '?') . " after_fmt=" . (isset($anchor['after']) ? fmt_amt((string)$anchor['after']) : '?') . "\n" . "No faucet re-mint. Live constitution is start-at-zero + daily emission.\n" . "Rent grant = % remaining faucet + flat(base+last year perf).\n", FILE_APPEND ); } /** * House / empire discovery ads (text). Compartment-local board only. * Recruits sister nosignup.* faces + rare aligned no-signup free tools. * Not a shared ad exchange - each site's pick is independent. */ function house_ad_roster(): array { return [ ['id' => 'house_chat', 'href' => 'https://nosignup.chat/', 'label' => 'nosignup.chat - free peer chat, no account', 'weight' => 3], ['id' => 'house_work', 'href' => 'https://nosignup.work/', 'label' => 'nosignup.work - jobs & resumes, no signup', 'weight' => 2], ['id' => 'house_market', 'href' => 'https://nosignup.market/', 'label' => 'nosignup.market - classifieds, no signup', 'weight' => 2], ['id' => 'house_date', 'href' => 'https://nosignup.date/', 'label' => 'nosignup.date - personals map, no signup', 'weight' => 2], ['id' => 'house_fun', 'href' => 'https://nosignup.fun/', 'label' => 'nosignup.fun - media organism, no signup', 'weight' => 2], ['id' => 'house_info', 'href' => 'https://nosignup.info/', 'label' => 'nosignup.info - free modules, no signup', 'weight' => 2], ['id' => 'house_com', 'href' => 'https://nosignup.com/', 'label' => 'nosignup.com - free forever pinwheel map', 'weight' => 2], ['id' => 'house_org', 'href' => 'https://nosignup.org/', 'label' => 'nosignup.org - free forever MIRROR pinwheel', 'weight' => 2], ['id' => 'house_net', 'href' => 'https://nosignup.net/', 'label' => 'nosignup.net - free map · MAP pinwheel', 'weight' => 2], ['id' => 'house_trade', 'href' => 'https://nosignup.trade/', 'label' => 'nosignup.trade - disposable NSU wallet, no signup', 'weight' => 3], // recruit free/no-signup creatives (email house; not a second economy) ['id' => 'house_recruit', 'href' => 'mailto:buysellfreetrade@proton.me?subject=free%2Fno-signup%20PNG%20house%20ad', 'label' => 'Free/no-signup PNG house ad → buysellfreetrade@proton.me', 'weight' => 2], // aligned free no-signup directory (external; spirit match, not a dependency) ['id' => 'house_fns', 'href' => 'https://freenosignup.com/', 'label' => 'freenosignup.com - free tools, no account (aligned)', 'weight' => 1], ]; } function house_ad_pick(): array { $pool = house_ad_roster(); $total = 0; foreach ($pool as $h) { $total += max(1, (int)($h['weight'] ?? 1)); } $r = random_int(1, max(1, $total)); $acc = 0; $pick = $pool[0]; foreach ($pool as $h) { $acc += max(1, (int)($h['weight'] ?? 1)); if ($r <= $acc) { $pick = $h; break; } } return [ 'id' => (string)$pick['id'], 'active' => true, 'house' => true, 'stake_micros' => '0', 'stake_fmt' => 'house', 'href' => (string)$pick['href'], 'label' => (string)$pick['label'], 'creative_url' => '', 'creative_file' => '', ]; } /** Weighted random active bid that has a creative (site-local board). */ function ad_pick_weighted(): ?array { $e = load_economy(); $pool = []; $totalW = 0; foreach ($e['bids'] ?? [] as $b) { if (empty($b['active'])) continue; /* A TEXT AD IS AN AD. This used to require an uploaded image, so anyone * who staked NSU without a creative was charged and then silently shown * zero impressions - taking money and delivering nothing. The slot is a * small corner tile where a label and a link render perfectly well, and * ad_pick already returns img=null for imageless bids. Paying must always * buy the share it says it buys. */ $has = !empty($b['creative_url']) || !empty($b['creative_file']) || !empty($b['label']); if (!$has) continue; $stake = (string)($b['stake_micros'] ?? '0'); // weight ≈ stake / 0.01 NSU (10000 micros) at least 1 $w = 1; if (function_exists('bcdiv')) { $w = max(1, (int)bcdiv($stake, '10000', 0)); } else { $w = max(1, (int)floor(((float)$stake) / 10000)); } if ($w > 1000000) $w = 1000000; $pool[] = [$b, $w]; $totalW += $w; } // Allotted luck: empty board → house discovery; else rare house roll (empire gossip) if ($totalW < 1 || !$pool) { return house_ad_pick(); } if (random_int(1, 100) <= NST_HOUSE_AD_CHANCE) { return house_ad_pick(); } $r = random_int(1, $totalW); $acc = 0; foreach ($pool as [$b, $w]) { $acc += $w; if ($r <= $acc) return $b; } return $pool[count($pool) - 1][0]; } /** Integer half of a micros string (floor). */ function dec_half(string $a): string { $a = dec_norm($a); if (function_exists('bcdiv')) return dec_norm(bcdiv($a, '2', 0)); $half = ''; $carry = 0; for ($i = 0; $i < strlen($a); $i++) { $n = $carry * 10 + (int)$a[$i]; $half .= (string)intdiv($n, 2); $carry = $n % 2; } return dec_norm($half); } /** Floor-divide decimal string by small positive int (e.g. treasury/1000 = 0.1%). */ function dec_div_small(string $a, int $d): string { $a = dec_norm($a); if ($d <= 1) return $a; if (function_exists('bcdiv')) return dec_norm(bcdiv($a, (string)$d, 0)); $n = ''; $rem = 0; for ($i = 0; $i < strlen($a); $i++) { $cur = $rem * 10 + (int)$a[$i]; $n .= (string)intdiv($cur, $d); $rem = $cur % $d; } return dec_norm($n); } /** * Bootstrap loose-spread order book when there are ZERO open orders. * Temp discovery only: anchor 1.0 NOTE/NSU → BUY @ 0.5, SELL @ 1.5 (micros). * Amount ≈ 0.1% of treasury each side so market makers can tighten. * kind=bootstrap_spread. Does NOT cancel live orders — only posts if book empty. * Discovery quotes only: fill API rejects kind=bootstrap_spread (not free NSU). * * MASTER FAUCET: treasury holds genesis supply (24_000_000 NSU KING start). * That wallet IS the empire faucet wallet for THIS site. Renters must NOT get * treasury.secret / site wallet seed (panel unlock material for owner only). * * @return array{ok:bool,skipped?:bool,reason?:string,err?:string,n?:int,posted?:int,amount_fmt?:string} */ function nst_bootstrap_spread_if_empty(): array { global $TFILE; boot_data(); $chain = read_chain(); $hasG = false; foreach ($chain as $r) { if (($r['type'] ?? '') === 'genesis') { $hasG = true; break; } } if (!$hasG) { return ['ok' => false, 'skipped' => true, 'reason' => 'no_genesis']; } $open = open_orders($chain); if (count($open) > 0) { return ['ok' => true, 'skipped' => true, 'reason' => 'open_orders', 'n' => count($open)]; } if (!is_file($TFILE)) { return ['ok' => false, 'err' => 'no_treasury_secret']; } $sec = trim((string)@file_get_contents($TFILE)); if ($sec === '') { return ['ok' => false, 'err' => 'empty_treasury_secret']; } $addr = addr_from_seed($sec); $bal = balances($chain); $have = (string)($bal[$addr] ?? '0'); $amt = dec_div_small($have, NST_BOOTSTRAP_DENOM); if (dec_cmp($amt, '1') < 0) { return ['ok' => false, 'err' => 'treasury_too_small', 'have_fmt' => fmt_amt($have)]; } // sell side locks NSU; with empty book all liquid is available if (dec_cmp($have, $amt) < 0) { return ['ok' => false, 'err' => 'Not enough free NSU for this action', 'have_fmt' => fmt_amt($have)]; } $buyPx = NST_BOOTSTRAP_BUY_PX; $sellPx = NST_BOOTSTRAP_SELL_PX; $anchor = NST_BOOTSTRAP_ANCHOR_PX; $ts = time(); $expiry = $ts + 30 * 86400; $pair = 'NSU/NOTE'; $posted = 0; foreach ([['buy', $buyPx], ['sell', $sellPx]] as [$side, $px]) { $id = hash('sha256', $addr . '|bootstrap|' . $side . '|' . $ts . '|' . $amt . '|' . $px . '|' . bin2hex(random_bytes(4))); $body = implode('|', ['order', '1', $id, $addr, $side, $pair, $amt, $px, (string)$expiry, (string)$ts]); $row = [ 'type' => 'order', 'ver' => 1, 'id' => $id, 'addr' => $addr, 'side' => $side, 'pair' => $pair, 'amount' => $amt, 'amount_fmt' => fmt_amt($amt), 'price' => $px, 'price_fmt' => fmt_amt($px), 'expiry' => $expiry, 'ts' => $ts, 'sig' => mac_sign($sec, $body), 'kind' => 'bootstrap_spread', 'anchor_px' => $anchor, 'note' => 'loose discovery spread around temp anchor 1.0 NOTE/NSU; makers should tighten', ]; if (!append_row($row)) { return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment', 'posted' => $posted]; } $posted++; } append_row([ 'type' => 'bootstrap_spread', 'ver' => 1, 'ts' => $ts, 'buy_px' => $buyPx, 'sell_px' => $sellPx, 'anchor_px' => $anchor, 'amount' => $amt, 'amount_fmt' => fmt_amt($amt), 'treasury_addr' => $addr, 'note' => 'seeded empty book: BUY@0.5 SELL@1.5 around 1.0; ~0.1% treasury each side; P still book-converge', ]); return [ 'ok' => true, 'posted' => $posted, 'amount_fmt' => fmt_amt($amt), 'buy_px_fmt' => fmt_amt($buyPx), 'sell_px_fmt' => fmt_amt($sellPx), 'anchor_px_fmt' => fmt_amt($anchor), ]; } /** * Converged market price P (NOTE micros per 1 NSU). Never a fixed sticker. * both sides → mid; else best bid; else best ask; else null (wait for market). */ function book_mid_micros(array $chain): ?string { $orders = open_orders($chain); $bids = array_values(array_filter($orders, fn($o) => ($o['side'] ?? '') === 'buy')); $asks = array_values(array_filter($orders, fn($o) => ($o['side'] ?? '') === 'sell')); usort($bids, fn($a, $b) => dec_cmp((string)($b['price'] ?? '0'), (string)($a['price'] ?? '0'))); usort($asks, fn($a, $b) => dec_cmp((string)($a['price'] ?? '0'), (string)($b['price'] ?? '0'))); if ($bids && $asks) { $sum = dec_add($bids[0]['price'] ?? '0', $asks[0]['price'] ?? '0'); return dec_half($sum); } if ($bids) return dec_norm((string)($bids[0]['price'] ?? '0')); if ($asks) return dec_norm((string)($asks[0]['price'] ?? '0')); return null; } /** * L3-RR293-LANDMARK nst_public_book — single state-assembly book slice (bids/asks/mid). * Shared by api=state and absolute-smallest api=book (copy book only, no full dump). * @return array{bids:list,asks:list,mid:?string,totals:array{bids:int,asks:int},omitted:array{bids:int,asks:int},truncated:bool,side_limit:int} */ function nst_public_book(array $chain): array { $orders = open_orders($chain); $bids = array_values(array_filter($orders, fn($o) => ($o['side'] ?? '') === 'buy')); $asks = array_values(array_filter($orders, fn($o) => ($o['side'] ?? '') === 'sell')); // Side-local sort (open_orders mixed comparator is not best-bid/best-ask safe). usort($bids, fn($a, $b) => dec_cmp((string)($b['price'] ?? '0'), (string)($a['price'] ?? '0'))); usort($asks, fn($a, $b) => dec_cmp((string)($a['price'] ?? '0'), (string)($b['price'] ?? '0'))); // Single source of P: same helper as SSR / buyback (never fixed sticker). $convergeP = book_mid_micros($chain); $mid = $convergeP !== null ? fmt_amt($convergeP) : null; $bidTotal = count($bids); $askTotal = count($asks); $bidRows = array_slice($bids, 0, NST_PUBLIC_BOOK_SIDE_MAX); $askRows = array_slice($asks, 0, NST_PUBLIC_BOOK_SIDE_MAX); $bidOmitted = max(0, $bidTotal - count($bidRows)); $askOmitted = max(0, $askTotal - count($askRows)); $recentFills = nst_public_recent_fills($chain); return [ 'bids' => $bidRows, 'asks' => $askRows, 'mid' => $mid, 'totals' => ['bids' => $bidTotal, 'asks' => $askTotal], 'omitted' => ['bids' => $bidOmitted, 'asks' => $askOmitted], 'truncated' => ($bidOmitted + $askOmitted) > 0, 'side_limit' => NST_PUBLIC_BOOK_SIDE_MAX, // Compact and complete: no current pair disappears merely because its // detailed row ranks below a bounded global publication slice. 'pairs' => nst_public_pair_summaries($bids, $asks, $recentFills), ]; } /** Complete compact pair universe plus exact best quotes/counts from current open rows. */ function nst_public_pair_summaries(array $bids, array $asks, array $recentFills): array { $pairs = []; $init = static function (string $pair) use (&$pairs): void { if (isset($pairs[$pair])) return; $pairs[$pair] = [ 'pair' => $pair, 'current' => false, 'fresh' => 0, 'bid_count' => 0, 'ask_count' => 0, 'fillable_ask_count' => 0, 'fill_count' => 0, 'best_bid' => null, 'best_ask' => null, 'last_fill' => null, ]; }; $quote = static function (array $row): array { $keep = ['type', 'id', 'addr', 'side', 'pair', 'amount', 'amount_fmt', 'remaining', 'remaining_fmt', 'price', 'price_fmt', 'expiry', 'ts', 'kind']; return array_intersect_key($row, array_fill_keys($keep, true)); }; foreach ($bids as $row) { $pair = strtoupper(trim((string)($row['pair'] ?? ''))); if ($pair === '') continue; $init($pair); $pairs[$pair]['current'] = true; $pairs[$pair]['bid_count']++; $pairs[$pair]['fresh'] = max((int)$pairs[$pair]['fresh'], (int)($row['ts'] ?? 0)); if ($pairs[$pair]['best_bid'] === null) $pairs[$pair]['best_bid'] = $quote($row); } foreach ($asks as $row) { $pair = strtoupper(trim((string)($row['pair'] ?? ''))); if ($pair === '') continue; $init($pair); $pairs[$pair]['current'] = true; $pairs[$pair]['ask_count']++; if (($row['kind'] ?? '') !== 'bootstrap_spread') $pairs[$pair]['fillable_ask_count']++; $pairs[$pair]['fresh'] = max((int)$pairs[$pair]['fresh'], (int)($row['ts'] ?? 0)); if ($pairs[$pair]['best_ask'] === null) $pairs[$pair]['best_ask'] = $quote($row); } foreach ($recentFills as $row) { $pair = strtoupper(trim((string)($row['pair'] ?? ''))); if ($pair === '' || ($row['type'] ?? '') !== 'fill') continue; $init($pair); $pairs[$pair]['fill_count']++; $pairs[$pair]['fresh'] = max((int)$pairs[$pair]['fresh'], (int)($row['ts'] ?? 0)); $pairs[$pair]['last_fill'] = $quote($row); } $out = array_values($pairs); usort($out, static function (array $a, array $b): int { if ((bool)$a['current'] !== (bool)$b['current']) return !empty($a['current']) ? -1 : 1; if ((int)$a['fresh'] !== (int)$b['fresh']) return (int)$b['fresh'] <=> (int)$a['fresh']; return strcmp((string)$a['pair'], (string)$b['pair']); }); return $out; } /** Recent exact-pair price history. Open/cancelled orders never manufacture charts. */ function nst_public_recent_fills(array $chain): array { $fills = []; foreach ($chain as $row) { if (($row['type'] ?? '') !== 'fill') continue; $pair = strtoupper(trim((string)($row['pair'] ?? ''))); if ($pair === '') continue; $fills[] = $row; } return array_slice($fills, -NST_PUBLIC_RECENT_FILL_MAX); } /** Buyback floor price = P/2 from market convergence. */ function nst_buyback_price_micros(array $chain): ?string { $p = book_mid_micros($chain); if ($p === null || dec_cmp($p, '2') < 0) return null; $half = dec_half($p); return dec_cmp($half, '1') < 0 ? '1' : $half; } /** * Arm treasury BUY order at converged P/2 for nsuAmount micros. * Stable floor: market sets P; we only bid half. No fixed list price. * Fail-open if no mid yet (returns err, does not block gift). */ function nst_arm_buyback_bids(string $nsuAmountMicros, string $reason): array { $nsuAmountMicros = dec_norm($nsuAmountMicros); if (dec_cmp($nsuAmountMicros, '1') < 0) return ['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)']; $chain = read_chain(); $px = nst_buyback_price_micros($chain); if ($px === null) return ['ok' => false, 'err' => 'no_market_converge', 'note' => 'wait for book; P is never fixed']; $sec = treasury_secret(); $addr = addr_from_seed($sec); $ts = time(); $expiry = $ts + 30 * 86400; $pair = 'NSU/NOTE'; $id = hash('sha256', $addr . '|buyback|' . $ts . '|' . $nsuAmountMicros . '|' . $px . '|' . bin2hex(random_bytes(4))); $body = implode('|', ['order', '1', $id, $addr, 'buy', $pair, $nsuAmountMicros, $px, (string)$expiry, (string)$ts]); $row = [ 'type' => 'order', 'ver' => 1, 'id' => $id, 'addr' => $addr, 'side' => 'buy', 'pair' => $pair, 'amount' => $nsuAmountMicros, 'amount_fmt' => fmt_amt($nsuAmountMicros), 'price' => $px, 'price_fmt' => fmt_amt($px), 'expiry' => $expiry, 'ts' => $ts, 'sig' => mac_sign($sec, $body), 'kind' => 'buyback_floor', 'reason' => substr($reason, 0, 80), 'note' => 'stable floor: BUY at market_P/2 (P from book convergence)', ]; if (!append_row($row)) return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; return [ 'ok' => true, 'order' => $row, 'converge_p_fmt' => fmt_amt(book_mid_micros(read_chain()) ?? '0'), 'buyback_px_fmt' => fmt_amt($px), ]; } /** * OPERATOR_GO §3c: treasury BUY at half the sell price, same pair/amount. * Idempotent id = sha256(sellId|treasury_half_bid). Signed with treasury seed. * Quote-side NOTE settles off-chain (settle_ref belief) — same rail as any book BID. * Does not mint; open BID alone does not move balances (conservation held). * * @return array{ok:bool,order?:array,skipped?:bool,reason?:string,err?:string} */ function nst_treasury_half_bid_from_sell(string $sellId, string $pair, string $amount, string $sellPrice, int $expiry, int $ts, array $chain, callable $append, string $tsec, string $taddr): array { $sellId = preg_replace('/[^a-f0-9]/', '', strtolower($sellId)) ?? ''; if (strlen($sellId) !== 64) return ['ok' => false, 'err' => 'bad sell id']; $amount = dec_norm($amount); $sellPrice = dec_norm($sellPrice); if (dec_cmp($amount, '1') < 0 || dec_cmp($sellPrice, '1') < 0) return ['ok' => false, 'err' => 'amount/price']; $halfPx = dec_half($sellPrice); if (dec_cmp($halfPx, '1') < 0) $halfPx = '1'; $pair = strtoupper(trim($pair)); if ($pair === '') $pair = 'NSU/NOTE'; $bidId = hash('sha256', $sellId . '|treasury_half_bid'); foreach ($chain as $r) { if (($r['type'] ?? '') === 'order' && ($r['id'] ?? '') === $bidId) { return ['ok' => true, 'skipped' => true, 'reason' => 'already', 'order' => $r]; } } // Seed/addr MUST be pre-read outside nst_with_chain_lock — never call treasury_secret()/ensure_genesis under LOCK_EX (deadlock). $sec = $tsec; $addr = strtolower($taddr); if ($sec === '' || strlen($addr) !== 64) return ['ok' => false, 'err' => 'no treasury material']; $body = implode('|', ['order', '1', $bidId, $addr, 'buy', $pair, $amount, $halfPx, (string)$expiry, (string)$ts]); $row = [ 'type' => 'order', 'ver' => 1, 'id' => $bidId, 'addr' => $addr, 'side' => 'buy', 'pair' => $pair, 'amount' => $amount, 'amount_fmt' => fmt_amt($amount), 'price' => $halfPx, 'price_fmt' => fmt_amt($halfPx), 'expiry' => $expiry, 'ts' => $ts, 'sig' => mac_sign($sec, $body), 'kind' => 'treasury_half_bid', 'paired_sell_id' => $sellId, 'note' => 'OPERATOR_GO §3c: half-price treasury BID floor paired to treasury SELL', ]; if (!$append($row)) return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; return ['ok' => true, 'order' => $row]; } /** * OPERATOR_GO §4 E2a: KING places a small REAL fillable SELL-ask ladder. * Not bootstrap_spread (those stay discovery-only / non-fillable). * Each SELL is an ordinary book order signed by treasury → C2 half-BID arms. * Deterministic ids (sha256 taddr|treasury_ladder_v1|price) → re-call is idempotent. * Seed/addr MUST be pre-read outside nst_with_chain_lock (same deadlock law as C2). * * @return array{ok:bool,posted?:int,skipped?:int,orders?:array,half_bids?:array,err?:string,amount_fmt?:string} */ function nst_treasury_ask_ladder(string $tsec, string $taddr, ?string $amountMicros = null): array { $tsec = (string)$tsec; $taddr = strtolower(trim($taddr)); if ($tsec === '' || strlen($taddr) !== 64) { return ['ok' => false, 'err' => 'no treasury material']; } $amt = $amountMicros !== null && $amountMicros !== '' ? dec_norm($amountMicros) : parse_amt(NST_LADDER_AMT_NSU); if ($amt === null || dec_cmp($amt, '1') < 0) { return ['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)']; } $amtMax = parse_amt(NST_LADDER_AMT_MAX_NSU); if ($amtMax !== null && dec_cmp($amt, $amtMax) > 0) { return ['ok' => false, 'err' => 'amount exceeds ladder cap', 'max_fmt' => fmt_amt($amtMax)]; } $prices = [NST_LADDER_PX_LO, NST_LADDER_PX_MID, NST_LADDER_PX_HI]; $pair = 'NSU/NOTE'; $ts = time(); $expiry = $ts + 30 * 86400; $out = nst_with_chain_lock(function (array $chain, callable $append) use ( $tsec, $taddr, $amt, $prices, $pair, $ts, $expiry ) { $bal = balances($chain); $have = (string)($bal[$taddr] ?? '0'); $av = available_nsu($bal, $chain, $taddr); $need = '0'; foreach ($prices as $px) { $need = dec_add($need, $amt); } if (dec_cmp($av, $need) < 0) { return [ 'ok' => false, 'err' => 'Not enough free NSU — cancel open sells or buy more on the book', 'available_fmt' => fmt_amt($av), 'need_fmt' => fmt_amt($need), 'treasury_fmt' => fmt_amt($have), ]; } $nOpen = 0; foreach (open_orders($chain) as $o) { if (($o['addr'] ?? '') === $taddr) { $nOpen++; } } // Each new sell + half-BID needs 2 slots; leave headroom under 20. $slotsNeeded = count($prices) * 2; if ($nOpen + $slotsNeeded > 20) { return ['ok' => false, 'err' => 'too many open orders', 'open' => $nOpen]; } $posted = 0; $skipped = 0; $orders = []; $halfBids = []; foreach ($prices as $px) { $px = dec_norm($px); if (dec_cmp($px, '1') < 0) { continue; } $id = hash('sha256', $taddr . '|treasury_ladder_v1|' . $px); $exists = false; foreach ($chain as $r) { if (($r['type'] ?? '') === 'order' && ($r['id'] ?? '') === $id) { $exists = true; break; } } if ($exists) { $skipped++; continue; } $body = implode('|', ['order', '1', $id, $taddr, 'sell', $pair, $amt, $px, (string)$expiry, (string)$ts]); $row = [ 'type' => 'order', 'ver' => 1, 'id' => $id, 'addr' => $taddr, 'side' => 'sell', 'pair' => $pair, 'amount' => $amt, 'amount_fmt' => fmt_amt($amt), 'price' => $px, 'price_fmt' => fmt_amt($px), 'expiry' => $expiry, 'ts' => $ts, 'sig' => mac_sign($tsec, $body), 'kind' => 'treasury_ladder', 'note' => 'OPERATOR_GO §4: KING real fillable SELL ladder (buy path); not bootstrap discovery', ]; if (!$append($row)) { return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment', 'posted' => $posted, 'orders' => $orders]; } $hb = nst_treasury_half_bid_from_sell( $id, $pair, $amt, $px, $expiry, $ts, $chain, $append, $tsec, $taddr ); if (empty($hb['ok'])) { return [ 'ok' => false, 'err' => 'treasury half-bid failed after ladder sell', 'detail' => $hb['err'] ?? 'unknown', 'posted' => $posted, 'order' => $row, ]; } $posted++; $orders[] = $row; if (!empty($hb['order'])) { $halfBids[] = $hb['order']; } // Keep open-count honest for later rungs in same lock. $nOpen += 2; } return [ 'ok' => true, 'posted' => $posted, 'skipped' => $skipped, 'orders' => $orders, 'half_bids' => $halfBids, 'amount_fmt' => fmt_amt($amt), 'prices_fmt' => array_map('fmt_amt', $prices), 'note' => 'Real fillable treasury asks on book; visitors TAKE ORDER / ?api=fill. NOTE settles off-chain (settle_ref).', ]; }); if ($out === null) { return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; } return $out; } /** * Donate → NSU gift (mid×k or direct), then 50% profit pile / 50% ad-bid stake. * Faucet free gas does NOT use this path. */ function economy_credit_donate(string $toAddr, string $nsuMicros, string $txid, string $asset, string $extAmt): array { $nsuMicros = dec_norm($nsuMicros); if (dec_cmp($nsuMicros, '1') < 0) return ['ok' => false, 'err' => 'gift too small']; $sec = treasury_secret(); $from = addr_from_seed($sec); // half ad stake / half profit pile; then arm BUYBACK at market P/2 for the ad half // (stable floor: P converges from book — never a fixed sticker price) $half = dec_half($nsuMicros); $bidPart = $half; $profitPart = dec_sub($nsuMicros, $bidPart); $ts = time(); $nonce = bin2hex(random_bytes(8)); $memo = 'donate:gift:' . substr($txid !== '' ? $txid : 'honor', 0, 40); $tbody = implode('|', ['transfer', '1', $from, $toAddr, $nsuMicros, $nonce, (string)$ts, $memo]); $row = [ 'type' => 'transfer', 'ver' => 1, 'from' => $from, 'to' => $toAddr, 'amount' => $nsuMicros, 'amount_fmt' => fmt_amt($nsuMicros), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => mac_sign($sec, $tbody), 'kind' => 'donate_gift', 'txid' => substr($txid, 0, 128), 'asset' => substr($asset, 0, 16), ]; $splitRow = [ 'type' => 'economy_split', 'ver' => 1, 'ts' => $ts, 'gift_fmt' => fmt_amt($nsuMicros), 'profit_fmt' => fmt_amt($profitPart), 'ad_bid_fmt' => fmt_amt($bidPart), 'txid' => substr($txid, 0, 128), 'note' => '50% profit pile / 50% site ad bid (donate path); buyback arms at market P/2', ]; $creditOut = nst_with_chain_lock(function (array $chain, callable $append) use ( $from, $toAddr, $nsuMicros, $nonce, $row, $splitRow, $bidPart, $profitPart, $ts, $txid, $asset, $extAmt ) { $bal = balances($chain); $have = $bal[$from] ?? '0'; if (dec_cmp($have, $nsuMicros) < 0) { return ['ok' => false, 'err' => 'Treasury does not have enough free NSU for that — try a smaller amount', 'have_fmt' => fmt_amt($have)]; } foreach ($chain as $r) { if (($r['type'] ?? '') === 'transfer' && ($r['from'] ?? '') === $from && ($r['nonce'] ?? '') === $nonce) { return ['ok' => false, 'err' => 'That transfer was already sent (nonce replay) — use a new nonce']; } } if (!$append($row)) return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; $e = load_economy(); $e['profit_micros'] = dec_add((string)($e['profit_micros'] ?? '0'), $profitPart); $e['ad_bid_micros'] = dec_add((string)($e['ad_bid_micros'] ?? '0'), $bidPart); $bid = [ 'id' => hash('sha256', $toAddr . '|' . $ts . '|' . $bidPart . '|' . $nonce), 'stake_micros' => $bidPart, 'stake_fmt' => fmt_amt($bidPart), 'ts' => $ts, 'from_donate' => true, 'txid' => substr($txid, 0, 128), 'active' => true, ]; if (!isset($e['bids']) || !is_array($e['bids'])) $e['bids'] = []; $e['bids'][] = $bid; if (count($e['bids']) > 200) $e['bids'] = array_slice($e['bids'], -200); if (!isset($e['donations']) || !is_array($e['donations'])) $e['donations'] = []; $e['donations'][] = [ 'ts' => $ts, 'to' => $toAddr, 'gift_micros' => $nsuMicros, 'gift_fmt' => fmt_amt($nsuMicros), 'profit' => $profitPart, 'ad_bid' => $bidPart, 'txid' => substr($txid, 0, 128), 'asset' => substr($asset, 0, 16), 'ext' => substr($extAmt, 0, 40), ]; if (count($e['donations']) > 200) $e['donations'] = array_slice($e['donations'], -200); save_economy($e); if (!$append($splitRow)) { return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; } return [ 'ok' => true, 'tx' => $row, 'gift_fmt' => fmt_amt($nsuMicros), 'profit_fmt' => fmt_amt($profitPart), 'ad_bid_fmt' => fmt_amt($bidPart), 'economy' => [ 'profit_fmt' => fmt_amt((string)$e['profit_micros']), 'ad_bid_fmt' => fmt_amt((string)$e['ad_bid_micros']), ], ]; }); if ($creditOut === null) return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; if (empty($creditOut['ok'])) return $creditOut; // Lock floor: treasury BUY at converged P/2 for the ad-half quantity (fail-open if no book yet) $bb = nst_arm_buyback_bids($bidPart, 'donate_buyback'); if (!empty($bb['ok'])) { append_row([ 'type' => 'buyback_arm', 'ver' => 1, 'ts' => time(), 'amount_fmt' => fmt_amt($bidPart), 'buyback_px_fmt' => $bb['buyback_px_fmt'] ?? null, 'order_id' => $bb['order']['id'] ?? null, 'note' => 'market-converged P/2 floor bid (not fixed list price)', ]); } return [ 'ok' => true, 'tx' => $creditOut['tx'] ?? $row, 'gift_fmt' => $creditOut['gift_fmt'] ?? fmt_amt($nsuMicros), 'profit_fmt' => $creditOut['profit_fmt'] ?? fmt_amt($profitPart), 'ad_bid_fmt' => $creditOut['ad_bid_fmt'] ?? fmt_amt($bidPart), 'buyback' => $bb, 'price_policy' => 'market_converge_P_then_bid_P_over_2', 'economy' => $creditOut['economy'] ?? null, ]; } /** Convert external amount + book mid → NSU micros. mid = quote per 1 NSU (in micros of quote). */ function donate_nsu_from_external(string $extHuman, ?string $midMicros, string $k): ?string { $ext = parse_amt($extHuman); if ($ext === null || dec_cmp($ext, '1') < 0) return null; // If mid present: nsu = ext / mid * k (all integer micros) // ext and mid same decimal basis (6). nsu_micros = ext * 1e6 / mid * k ≈ ext/mid when k=1 // Actually both are already micros: nsu = floor(ext * k / mid) when mid is price in quote-micros per NSU unit? // price_fmt "2" means 2 NOTE per NSU → price micros = 2000000. amount 1 NSU = 1000000 micros. // gift NSU micros = ext_micros * 1e6 / mid_micros ... use: nsu = ext / mid * 10^0 in unit space. // unit: nsu_units = ext_units / mid_units * k → micros: nsu_m = ext_m * k / mid_m * 10^6 / 10^6 = ext*k/mid if ($midMicros !== null && dec_cmp($midMicros, '1') > 0) { if (function_exists('bcmul') && function_exists('bcdiv')) { $num = bcmul($ext, $k, 0); return dec_norm(bcdiv($num, $midMicros, 0)); } // fallback float for small values only $n = (float)$ext * (float)$k / (float)$midMicros; if ($n < 1) return null; return dec_norm((string)(int)floor($n)); } // no mid: nsu = ext * k if (function_exists('bcmul')) return dec_norm(bcmul($ext, $k, 0)); return dec_mul_small($ext, max(1, (int)$k)); } function ensure_meta_epochs(): array { $m = load_meta(); $now = time(); if (empty($m['epoch_start'])) { $m['epoch_start'] = $now; $m['epoch_n'] = 1; } // advance yearly reset windows until current; roll economy stakes forward while (($m['epoch_start'] + NST_RESET_SECS) <= $now) { $fromN = (int)($m['epoch_n'] ?? 1); $m['epoch_start'] = (int)$m['epoch_start'] + NST_RESET_SECS; $m['epoch_n'] = $fromN + 1; economy_on_epoch_roll($fromN, (int)$m['epoch_n']); } if (empty($m['created'])) $m['created'] = $now; $m['version'] = NST_VERSION; save_meta($m); /* Anchor the crop registry BEFORE the first emission can spend against it, * so the ten wallets that receive issuance are on the ledger rather than * only in a file the King can rewrite. Idempotent - it writes once, ever. */ if (function_exists('nst_crop_registry_anchor')) { @nst_crop_registry_anchor(); } /* Emission rides the same page-load path as the epoch roll. It is * idempotent per UTC day and decides from the CHAIN, so extra calls * cost a replay and mint nothing. Deliberately AFTER save_meta so a * mint failure can never leave the epoch half-rolled. */ if (function_exists('nst_emission_run_daily')) { @nst_emission_run_daily(); } return $m; } function epoch_info(): array { $m = ensure_meta_epochs(); $start = (int)$m['epoch_start']; $end = $start + NST_RESET_SECS; $winEnd = $start + NST_EARLY_WINDOW_SECS; $now = time(); return [ 'epoch_n' => (int)($m['epoch_n'] ?? 1), 'epoch_start' => $start, 'epoch_end' => $end, 'early_window_end' => $winEnd, 'in_early_window' => $now >= $start && $now < $winEnd, 'secs_to_window_end' => max(0, $winEnd - $now), 'secs_to_reset' => max(0, $end - $now), ]; } /** Read-only epoch snapshot for the pre-ENTER HTML shell. Never rolls or emits. */ function nst_epoch_info_read_only(): array { $m = load_meta(); $now = time(); $start = (int)($m['epoch_start'] ?? $m['created'] ?? $now); $number = max(1, (int)($m['epoch_n'] ?? 1)); if ($start < 1) $start = $now; if ($start + NST_RESET_SECS <= $now) { $elapsed = intdiv(max(0, $now - $start), NST_RESET_SECS); $start += $elapsed * NST_RESET_SECS; $number += $elapsed; } $end = $start + NST_RESET_SECS; $winEnd = $start + NST_EARLY_WINDOW_SECS; return [ 'epoch_n' => $number, 'epoch_start' => $start, 'epoch_end' => $end, 'early_window_end' => $winEnd, 'in_early_window' => $now >= $start && $now < $winEnd, 'secs_to_window_end' => max(0, $winEnd - $now), 'secs_to_reset' => max(0, $end - $now), 'read_only' => true, ]; } /** Rentable crop ids (empire ten). Quotes only — settle/bind is a later residual. */ function nst_rent_site_ids(): array { return ['com', 'org', 'net', 'trade', 'chat', 'work', 'market', 'date', 'fun', 'info']; } /** Path to durable rent quote map (data/rent_quotes.json). */ function nst_rent_quotes_path(): string { global $RENT_QUOTES_FILE; return $RENT_QUOTES_FILE; } /** * Exclusive RMW on rent_quotes.json via flock. * Callback: fn(array $state): array — return new full state to write (or same). * State shape: ['quotes' => [quote_id => row, ...], 'updated' => ts] * @return mixed callback result, or null on lock/open failure */ function nst_with_rent_quotes_lock(callable $fn) { boot_data(); $path = nst_rent_quotes_path(); return nst_with_file_lock($path, function ($fh) use ($fn) { rewind($fh); $raw = stream_get_contents($fh); $state = ['quotes' => [], 'updated' => 0]; if (is_string($raw) && trim($raw) !== '') { $j = json_decode($raw, true); if (is_array($j)) { $state = array_merge($state, $j); if (!isset($state['quotes']) || !is_array($state['quotes'])) { $state['quotes'] = []; } } } $ret = $fn($state); // If callback returned a state-shaped array with quotes, persist it. if (is_array($ret) && isset($ret['quotes']) && is_array($ret['quotes'])) { $ret['updated'] = time(); $toWrite = $ret; unset($toWrite['_last']); // never persist helper side-channel $blob = j($toWrite); ftruncate($fh, 0); rewind($fh); if (fwrite($fh, $blob) === false) { return null; } fflush($fh); } return $ret; }); } /** Drop expired open quotes; keep paid ~7d for first-paid-wins / audit; thrift cap. */ function nst_rent_quotes_prune(array $state, int $now): array { $quotes = $state['quotes'] ?? []; if (!is_array($quotes)) { $quotes = []; } $kept = []; $paidKeepSecs = 7 * 86400; foreach ($quotes as $id => $row) { if (!is_array($row)) { continue; } $st = (string)($row['status'] ?? 'open'); $qid = (string)($row['quote_id'] ?? $id); if ($qid === '') { continue; } if ($st === 'paid') { $paidTs = (int)($row['paid_ts'] ?? 0); if ($paidTs > 0 && ($now - $paidTs) > $paidKeepSecs) { continue; } $kept[$qid] = $row; continue; } if ($st !== 'open') { continue; } $exp = (int)($row['expires'] ?? 0); if ($exp > 0 && $exp < $now) { continue; } $kept[$qid] = $row; } // Cap: prefer paid + newest open; hard ceiling 250 rows if (count($kept) > 250) { uasort($kept, static function ($a, $b) { $sa = (string)($a['status'] ?? ''); $sb = (string)($b['status'] ?? ''); if ($sa === 'paid' && $sb !== 'paid') { return -1; } if ($sb === 'paid' && $sa !== 'paid') { return 1; } return ((int)($b['created'] ?? 0)) <=> ((int)($a['created'] ?? 0)); }); $kept = array_slice($kept, 0, 250, true); } $state['quotes'] = $kept; return $state; } /** * Persist one open quote. Returns stored row or null. * @param array $row must include quote_id, site, pay_to, amount, memo, expires, … */ function nst_rent_quote_store(array $row): ?array { $qid = (string)($row['quote_id'] ?? ''); if ($qid === '' || !preg_match('/^[a-f0-9]{16,64}$/', $qid)) { return null; } $now = time(); $out = nst_with_rent_quotes_lock(function (array $state) use ($row, $qid, $now) { $state = nst_rent_quotes_prune($state, $now); $row['status'] = 'open'; $row['created'] = (int)($row['created'] ?? $now); $state['quotes'][$qid] = $row; // Return full state so lock helper writes; also stash row for return via side channel $state['_last'] = $row; return $state; }); if (!is_array($out) || empty($out['_last']) || !is_array($out['_last'])) { return null; } return $out['_last']; } /** Lookup quote by id (open non-expired, or paid). For settle residual / verify. */ function nst_rent_quote_get(string $quoteId): ?array { $quoteId = strtolower(preg_replace('/[^a-f0-9]/', '', $quoteId) ?? ''); if ($quoteId === '') { return null; } $now = time(); $found = null; nst_with_rent_quotes_lock(function (array $state) use ($quoteId, $now, &$found) { $state = nst_rent_quotes_prune($state, $now); $row = $state['quotes'][$quoteId] ?? null; if (is_array($row)) { $st = (string)($row['status'] ?? ''); if ($st === 'paid') { $found = $row; } elseif ($st === 'open') { $exp = (int)($row['expires'] ?? 0); if ($exp <= 0 || $exp >= $now) { $found = $row; } } } return $state; // write pruned state }); return $found; } /** Parse memo rent:{site}:{quote_id}. */ function nst_rent_parse_memo(string $memo): ?array { $memo = trim($memo); if (!preg_match('/^rent:([a-z]+):([a-f0-9]{16,64})$/i', $memo, $m)) { return null; } return ['site' => strtolower($m[1]), 'quote_id' => strtolower($m[2])]; } /** Settle failure reasons that should auto-refund the payer (P3d). */ function nst_rent_refundable_reason(string $reason): bool { return in_array($reason, [ 'already_paid', 'amount_mismatch', 'quote_missing', 'quote_expired', 'quote_not_open', 'site_mismatch', 'pay_to_mismatch', 'already_leased', 'bad_site', ], true); } /** * P3d: refund a failed rent pay from treasury → original payer (conserved). * Idempotent per inbound transfer nonce (memo rent:refund:{qid}:{nonce}). * @param array $failedTx original inbound transfer to treasury * @param array $settle result of nst_rent_try_settle_transfer * @return array{ok:bool,refunded?:bool,reason?:string,tx?:array,err?:string} */ function nst_rent_auto_refund(array $failedTx, array $settle): array { if (!empty($settle['settled'])) { return ['ok' => true, 'refunded' => false, 'reason' => 'settled_no_refund']; } $why = (string)($settle['reason'] ?? ''); if (!nst_rent_refundable_reason($why)) { return ['ok' => true, 'refunded' => false, 'reason' => 'not_refundable:' . $why]; } if (function_exists('nst_conservation_is_frozen') && nst_conservation_is_frozen()) { return ['ok' => false, 'refunded' => false, 'err' => 'writes frozen']; } $payer = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($failedTx['from'] ?? '')) ?? ''); $amount = dec_norm((string)($failedTx['amount'] ?? '0')); $inNonce = substr((string)($failedTx['nonce'] ?? ''), 0, 64); $parsed = nst_rent_parse_memo((string)($failedTx['memo'] ?? '')); $qid = $parsed['quote_id'] ?? 'unknown'; if (strlen($payer) !== 64 || !dec_ok($amount) || dec_cmp($amount, '1') < 0 || $inNonce === '') { return ['ok' => false, 'refunded' => false, 'err' => 'bad_failed_tx']; } $sec = treasury_secret(); $from = strtolower(addr_from_seed($sec)); $taddr = strtolower(treasury_addr()); if ($from !== $taddr) { return ['ok' => false, 'refunded' => false, 'err' => 'treasury_mismatch']; } // Idempotent refund nonce derived from inbound nonce (stable). $refundNonce = substr(hash('sha256', 'rent-refund-v1|' . $inNonce), 0, 32); $refundMemo = 'rent:refund:' . $qid . ':' . $inNonce; $ts = time(); $body = implode('|', ['transfer', '1', $from, $payer, $amount, $refundNonce, (string)$ts, $refundMemo]); $row = [ 'type' => 'transfer', 'ver' => 1, 'from' => $from, 'to' => $payer, 'amount' => $amount, 'amount_fmt' => fmt_amt($amount), 'nonce' => $refundNonce, 'ts' => $ts, 'memo' => $refundMemo, 'sig' => mac_sign($sec, $body), 'sig_mode' => 'server_mac', 'kind' => 'rent_refund', 'rent_refund_of_nonce' => $inNonce, 'rent_refund_reason' => $why, 'quote_id' => $qid, ]; $out = nst_with_chain_lock(function (array $chain, callable $append) use ($from, $payer, $amount, $refundNonce, $inNonce, $row) { foreach ($chain as $r) { if (($r['type'] ?? '') !== 'transfer') { continue; } // already refunded this inbound pay if ((string)($r['rent_refund_of_nonce'] ?? '') === $inNonce) { return ['ok' => true, 'refunded' => false, 'reason' => 'already_refunded', 'tx' => $r]; } if ((string)($r['nonce'] ?? '') === $refundNonce && strtolower((string)($r['from'] ?? '')) === $from) { return ['ok' => true, 'refunded' => false, 'reason' => 'already_refunded', 'tx' => $r]; } } $bal = balances($chain); $have = $bal[$from] ?? '0'; if (dec_cmp($have, $amount) < 0) { return [ 'ok' => false, 'refunded' => false, 'err' => 'treasury insufficient for refund', 'have_fmt' => fmt_amt($have), 'need_fmt' => fmt_amt($amount), ]; } if (!$append($row)) { return ['ok' => false, 'refunded' => false, 'err' => 'Could not save to the server — try again in a moment']; } return [ 'ok' => true, 'refunded' => true, 'tx' => $row, 'treasury_bal_fmt' => fmt_amt(dec_sub($have, $amount)), 'payer_credit_fmt' => fmt_amt($amount), ]; }); if ($out === null) { return ['ok' => false, 'refunded' => false, 'err' => 'Could not save to the server — try again in a moment']; } $out['reason'] = $why; $out['law'] = 'auto-refund failed rent pay · conserved · idempotent per inbound nonce'; return $out; } /** * P3c: try to settle a rent payment from an on-chain transfer row. * first-paid-wins · memo+amount+pay_to match · bind operator_addr = payer (no server seed). * P3d: caller may auto-refund when reason is refundable. * @param array $tx transfer row (from,to,amount,memo,nonce,ts,…) * @return array{settled:bool,reason?:string,site?:string,payer?:string,quote_id?:string,lease?:array} */ function nst_rent_try_settle_transfer(array $tx): array { $parsed = nst_rent_parse_memo((string)($tx['memo'] ?? '')); if ($parsed === null) { return ['settled' => false, 'reason' => 'not_rent_memo']; } $site = $parsed['site']; $qid = $parsed['quote_id']; if (!in_array($site, nst_rent_site_ids(), true)) { return ['settled' => false, 'reason' => 'bad_site']; } $to = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($tx['to'] ?? '')) ?? ''); $from = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($tx['from'] ?? '')) ?? ''); $amount = dec_norm((string)($tx['amount'] ?? '0')); if (strlen($to) !== 64 || strlen($from) !== 64 || !dec_ok($amount) || dec_cmp($amount, '1') < 0) { return ['settled' => false, 'reason' => 'bad_tx_fields']; } $taddr = strtolower(treasury_addr()); if ($to !== $taddr) { return ['settled' => false, 'reason' => 'not_to_treasury']; } $result = ['settled' => false, 'reason' => 'lock_failed']; $lockRet = nst_with_rent_quotes_lock(function (array $state) use ($site, $qid, $from, $to, $amount, $taddr, $tx, &$result) { $now = time(); $state = nst_rent_quotes_prune($state, $now); $row = $state['quotes'][$qid] ?? null; if (!is_array($row)) { $result = ['settled' => false, 'reason' => 'quote_missing']; return $state; } $st = (string)($row['status'] ?? 'open'); if ($st === 'paid') { $result = [ 'settled' => false, 'reason' => 'already_paid', 'winner' => (string)($row['payer'] ?? ''), 'quote_id' => $qid, ]; return $state; } if ($st !== 'open') { $result = ['settled' => false, 'reason' => 'quote_not_open', 'status' => $st]; return $state; } $exp = (int)($row['expires'] ?? 0); if ($exp > 0 && $exp < $now) { $result = ['settled' => false, 'reason' => 'quote_expired']; return $state; } if (strtolower((string)($row['site'] ?? '')) !== $site) { $result = ['settled' => false, 'reason' => 'site_mismatch']; return $state; } if (strtolower((string)($row['pay_to'] ?? '')) !== $taddr) { $result = ['settled' => false, 'reason' => 'pay_to_mismatch']; return $state; } if (dec_cmp(dec_norm((string)($row['amount'] ?? '0')), $amount) !== 0) { $result = ['settled' => false, 'reason' => 'amount_mismatch', 'expected' => (string)($row['amount'] ?? '0'), 'got' => $amount]; return $state; } // trade crop: refuse if already leased to someone else $e = load_economy(); if ($site === 'trade') { $opHave = strtolower(trim((string)($e['operator_addr'] ?? ''))); $rented = !empty($e['rented']) && $opHave !== '' && $opHave !== $taddr; if ($rented) { $result = ['settled' => false, 'reason' => 'already_leased', 'operator_addr' => $opHave]; return $state; } } $row['status'] = 'paid'; $row['payer'] = $from; $row['paid_ts'] = $now; $row['paid_tx'] = [ 'from' => $from, 'to' => $to, 'amount' => $amount, 'nonce' => (string)($tx['nonce'] ?? ''), 'ts' => (int)($tx['ts'] ?? $now), 'memo' => (string)($tx['memo'] ?? ''), ]; $state['quotes'][$qid] = $row; $lease = [ 'operator_addr' => $from, 'rented_at' => $now, 'quote_id' => $qid, 'site' => $site, 'epoch_n' => (int)($row['epoch_n'] ?? 0), 'amount' => $amount, 'amount_fmt' => fmt_amt($amount), ]; if ($site === 'trade') { $e['rented'] = true; $e['operator_addr'] = $from; $e['rented_at'] = $now; $e['rent_quote_id'] = $qid; $e['rent_payer'] = $from; } if (!isset($e['leases']) || !is_array($e['leases'])) { $e['leases'] = []; } $e['leases'][$site] = $lease; save_economy($e); if ($site === 'trade') { global $OP_ADDR_FILE; boot_data(); file_put_contents($OP_ADDR_FILE, $from . "\n", LOCK_EX); @chmod($OP_ADDR_FILE, 0644); } $result = [ 'settled' => true, 'site' => $site, 'payer' => $from, 'quote_id' => $qid, 'lease' => $lease, 'law' => 'first-paid-wins · operator_addr = payer key · no server LORD seed', ]; return $state; }); if ($lockRet === null) { return ['settled' => false, 'reason' => 'lock_failed']; } return $result; } /** * Scan chain for a transfer that pays a given open quote (or any open quote if id empty). * Idempotent: already-paid quotes report already_paid. */ function nst_rent_settle_scan(?string $quoteId = null): array { $quoteId = $quoteId !== null ? strtolower(preg_replace('/[^a-f0-9]/', '', $quoteId) ?? '') : ''; $chain = read_chain(); $taddr = strtolower(treasury_addr()); $attempts = []; $settled = null; foreach (array_reverse($chain) as $r) { if (($r['type'] ?? '') !== 'transfer') { continue; } if (strtolower((string)($r['to'] ?? '')) !== $taddr) { continue; } $parsed = nst_rent_parse_memo((string)($r['memo'] ?? '')); if ($parsed === null) { continue; } if ($quoteId !== '' && $parsed['quote_id'] !== $quoteId) { continue; } $out = nst_rent_try_settle_transfer($r); $refund = null; if (empty($out['settled']) && nst_rent_refundable_reason((string)($out['reason'] ?? ''))) { $refund = nst_rent_auto_refund($r, $out); } // Fail-open server_notice after money/rent residual commits (scan path). try { if (!empty($out['settled'])) { nst_inbox_notify_rent_settle($out); } if (is_array($refund) && !empty($refund['refunded'])) { nst_inbox_notify_rent_refund($refund); } } catch (Throwable $e) { // never abort settle/refund residual } $attempts[] = [ 'quote_id' => $parsed['quote_id'], 'from' => (string)($r['from'] ?? ''), 'result' => $out, 'refund' => $refund, ]; if (!empty($out['settled'])) { $settled = $out; break; } if (count($attempts) >= 40) { break; } } return [ 'ok' => true, 'settled' => $settled !== null, 'lease' => $settled['lease'] ?? null, 'settle' => $settled, 'attempts' => $attempts, 'law' => 'scan · settle first-paid-wins · auto-refund failed rent pays (P3d)', ]; } /** * Public rent quote (P3a+P3b). No chain write, no seed, no operator bind. * amount = NST_RENT_NSU_PER_DAY * days_left (days_left = ceil(secs_to_reset/86400), min 1). * P3b: persists open quote under flock for later settle match. * @return array JSON-ready */ function nst_rent_quote_public(string $site, string $treasuryAddr): array { $site = strtolower(trim($site)); if ($site === '' || $site === 'this' || $site === 'me') { $site = 'trade'; } $allowed = nst_rent_site_ids(); if (!in_array($site, $allowed, true)) { return [ 'ok' => false, 'http' => 400, 'err' => 'bad site', 'sites' => $allowed, ]; } $ep = epoch_info(); $secs = (int)($ep['secs_to_reset'] ?? 0); $daysLeft = (int)max(1, (int)ceil($secs / 86400)); $amountNsu = (int)NST_RENT_NSU_PER_DAY * $daysLeft; $amountMicros = parse_amt((string)$amountNsu); if ($amountMicros === null || dec_cmp($amountMicros, '1') < 0) { return ['ok' => false, 'http' => 500, 'err' => 'rent amount invalid']; } // This crop's economy only tracks trade lease today; other sites quote as free until multi-lease store. $e = load_economy(); $op = strtolower(trim((string)($e['operator_addr'] ?? ''))); $t = strtolower($treasuryAddr); $rentedHere = !empty($e['rented']) && $op !== '' && $op !== $t; if ($site === 'trade' && $rentedHere) { return [ 'ok' => true, 'quoted' => false, 'site' => $site, 'leased' => true, 'leased_until' => (int)($ep['epoch_end'] ?? 0), 'operator_addr' => $op, 'rented_at' => (int)($e['rented_at'] ?? 0), 'err' => 'already leased', 'law' => 'Occupied crop: no new quote until epoch wipe (or future multi-lease free).', ]; } $now = time(); $ttl = (int)NST_RENT_QUOTE_TTL_SECS; if ($ttl < 60) { $ttl = 1800; } $quoteId = bin2hex(random_bytes(12)); $memo = 'rent:' . $site . ':' . $quoteId; $expires = $now + $ttl; $storeRow = [ 'quote_id' => $quoteId, 'site' => $site, 'pay_to' => $treasuryAddr, 'amount' => $amountMicros, 'amount_nsu' => (string)$amountNsu, 'nsu_per_day' => (int)NST_RENT_NSU_PER_DAY, 'days_left' => $daysLeft, 'memo' => $memo, 'expires' => $expires, 'created' => $now, 'epoch_n' => (int)($ep['epoch_n'] ?? 0), 'epoch_end' => (int)($ep['epoch_end'] ?? 0), 'status' => 'open', ]; $stored = nst_rent_quote_store($storeRow); if ($stored === null) { return [ 'ok' => false, 'http' => 500, 'err' => 'quote store failed', 'durable' => false, 'settle' => false, ]; } return [ 'ok' => true, 'quoted' => true, 'site' => $site, 'leased' => false, 'quote_id' => $quoteId, 'pay_to' => $treasuryAddr, 'amount' => $amountMicros, 'amount_fmt' => fmt_amt($amountMicros), 'amount_nsu' => (string)$amountNsu, 'nsu_per_day' => (int)NST_RENT_NSU_PER_DAY, 'days_left' => $daysLeft, 'memo' => $memo, 'expires' => $expires, 'expires_in_secs' => $ttl, 'epoch_n' => (int)($ep['epoch_n'] ?? 0), 'epoch_end' => (int)($ep['epoch_end'] ?? 0), 'durable' => true, 'settle' => false, 'law' => 'Quote durable on this host (P3b data/rent_quotes.json). Pay-to-treasury settle + payer-key bind not shipped.', ]; } function load_mods(): array { global $MODS_FILE; if (!is_file($MODS_FILE)) return []; $j = json_decode((string)file_get_contents($MODS_FILE), true); return is_array($j) ? $j : []; } function save_mods(array $mods): void { global $MODS_FILE; file_put_contents($MODS_FILE, j($mods), LOCK_EX); } /** * RETIRED: THIS WAS A PER-HEAD PAYOUT WITH UNLIMITED HEADS. * * Any address could claim a PERMANENT 0.1 NSU/day draw on the treasury during a * 30-day window, proving nothing. Addresses are sha256 of a seed, so they cost * nothing and there is no limit on how many you make: 5,000 generated wallets * captured the King's entire daily income (5% of emission, ~500 NSU) forever, * for the price of 5,000 HTTP posts. It drained rather than minted, so the 100M * ceiling held - but the treasury went to zero and stayed there. * * The charter already forbids exactly this, in as many words: * * "NEVER PAY PER HEAD. [...] Paying per visitor makes issuance = participants * x amount and the attacker chooses the participant count - one residential * IPv6 /64 is 18 quintillion addresses. An IP is not a scarce resource." * * The rule was written for UBI and the same arithmetic applies here; this * predates the rule and was never revisited. It also predates the economy it * spends from: it was designed when genesis handed the King 24,000,000 NSU, and * under zero-genesis the King holds 5% of a day's emission and cannot fund it. * * EXISTING grants are still honoured by lazy_accrue() - retiring a perk is not a * licence to confiscate one already given. Only new grants stop. * * If early-adopter rewards are wanted again, a LORD funds them from their own * crop stream against something scarce. The protocol does not pay per head. */ function grant_early_modifier(string $addr): array { $mods = load_mods(); if (!empty($mods[$addr]['permanent'])) { return ['granted' => false, 'reason' => 'already', 'mod' => $mods[$addr]]; } return ['granted' => false, 'reason' => 'per_head_payout_retired', 'note' => 'Permanent per-address income is retired: addresses are free, so it was an ' . 'unlimited claim on the treasury. See the NEVER PAY PER HEAD clause in the charter.']; } /** Lazy catch-up: pay floor(days elapsed)*0.1 from treasury if permanent modifier */ function lazy_accrue(string $addr): array { $modsPeek = load_mods(); if (empty($modsPeek[$addr]['permanent'])) { return ['ok' => true, 'paid_fmt' => '0', 'reason' => 'no_modifier']; } $nowPeek = time(); $lastPeek = (int)($modsPeek[$addr]['last_accrual_ts'] ?? $nowPeek); if ($lastPeek > $nowPeek) $lastPeek = $nowPeek; if (intdiv($nowPeek - $lastPeek, 86400) < 1) { return ['ok' => true, 'paid_fmt' => '0', 'reason' => 'too_soon', 'next_in' => 86400 - (($nowPeek - $lastPeek) % 86400)]; } $sec = treasury_secret(); $from = addr_from_seed($sec); // Chain RMW + re-load mods under lock so concurrent unlocks cannot double-pay days. $out = nst_with_chain_lock(function (array $chain, callable $append) use ($addr, $from, $sec) { $mods = load_mods(); if (empty($mods[$addr]['permanent'])) { return ['ok' => true, 'paid_fmt' => '0', 'reason' => 'no_modifier']; } $now = time(); $last = (int)($mods[$addr]['last_accrual_ts'] ?? $now); if ($last > $now) $last = $now; $days = intdiv($now - $last, 86400); if ($days < 1) { return ['ok' => true, 'paid_fmt' => '0', 'reason' => 'too_soon', 'next_in' => 86400 - (($now - $last) % 86400)]; } $pay = dec_mul_small(NST_MOD_DAILY_MICROS, $days); $bal = balances($chain); $have = $bal[$from] ?? '0'; if (dec_cmp($have, $pay) < 0) { // whole days treasury can afford only (lazy ketchup, no partial-day mint) $daysCan = 0; $payCan = '0'; for ($d = 1; $d <= $days; $d++) { $try = dec_mul_small(NST_MOD_DAILY_MICROS, $d); if (dec_cmp($try, $have) > 0) break; $daysCan = $d; $payCan = $try; } if ($daysCan < 1) { return ['ok' => false, 'err' => 'treasury empty', 'paid_fmt' => '0']; } $days = $daysCan; $pay = $payCan; } $ts = $now; $nonce = bin2hex(random_bytes(8)); $memo = 'modifier:daily:' . $days . 'd'; $tbody = implode('|', ['transfer', '1', $from, $addr, $pay, $nonce, (string)$ts, $memo]); $row = [ 'type' => 'transfer', 'ver' => 1, 'from' => $from, 'to' => $addr, 'amount' => $pay, 'amount_fmt' => fmt_amt($pay), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => mac_sign($sec, $tbody), 'kind' => 'modifier_accrual', ]; if (!$append($row)) { return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; } // advance last_accrual by whole days only (while still holding chain lock) $mods[$addr]['last_accrual_ts'] = $last + ($days * 86400); save_mods($mods); return ['ok' => true, 'paid_fmt' => fmt_amt($pay), 'days' => $days, 'tx' => $row]; }); if ($out === null) { return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment', 'paid_fmt' => '0']; } return $out; } function vault_dir(): string { global $VAULT, $VAULT_BASE; /* Production has one configured sibling vault and fails closed if it is * unavailable. Falling back under the document root turns a permissions * problem into a seed disclosure on nginx/static-server configurations. * Isolated CLI/Desktop fixtures deliberately configure VAULT_BASE=$ROOT; * that explicit test mode is not a production fallback. */ try { $dir = nst_device_wallet_safe_dir((string)$VAULT, (string)$VAULT_BASE, true); nst_device_wallet_private_marker($dir); if (is_writable($dir)) return $dir; } catch (Throwable $e) { // Fail below without manufacturing a second, public location. } throw new RuntimeException('vault_unavailable'); } /** Best-effort human vault copy with the same no-link/atomic-write gates as assignments. */ function nst_vault_write_private_file(string $filename, string $body): bool { if ($filename === '' || basename($filename) !== $filename || str_contains($filename, "\0")) return false; try { $dir = vault_dir(); $path = $dir . DIRECTORY_SEPARATOR . $filename; nst_device_wallet_atomic_replace($path, $body); $fh = nst_device_wallet_open_regular($path, 'rb'); $saved = @stream_get_contents($fh, strlen($body) + 1); @fclose($fh); return is_string($saved) && hash_equals($body, $saved); } catch (Throwable $e) { return false; } } /** Write human-only vault material. PHP auth uses HASH only (ethos: vault not hot-path). */ function vault_write_operator(string $plainPass, array $walletSeeds = []): void { $readme = "NOSIGNUP.TRADE VAULT - ROOT/OPERATOR ONLY\n" . "Not served by nginx (keep outside html/ or deny).\n" . "Panel door is site seed (SITE_WALLET_SEED.txt + data/site.seed). Ctrl+Alt+Shift+N is seed-only.\n" . "/controlpanel unlocks with panel site.seed; KING mint is treasury.secret + KING_FAUCET_SEED.txt.\n" . "Leftover admin password files are unlinked on boot/wipe. No password product path.\n" . "Optional WALLET_SEEDS.txt only if operator seeds were passed at write time.\n" . "Site wallet seed does not rotate on yearly wipe.\n" . "Generated: " . gmdate('c') . "\n"; nst_vault_write_private_file('README.txt', $readme); admin_pass_burn(); if ($walletSeeds) { $w = ''; foreach ($walletSeeds as $k => $v) $w .= strtoupper((string)$k) . "=" . trim((string)$v) . "\n"; nst_vault_write_private_file('WALLET_SEEDS.txt', $w); } } function admin_pass_burn(): void { global $ADMIN_HASH_FILE, $ADMIN_PASS_FILE; foreach ([$ADMIN_HASH_FILE, $ADMIN_PASS_FILE] as $p) { if (!is_string($p) || $p === '') continue; try { $info = nst_device_wallet_safe_file($p, true); if (!empty($info['exists']) && !empty($info['real'])) { @unlink($info['real']); } } catch (Throwable $e) { if (is_file($p) && !is_link($p)) @unlink($p); } } try { $vp = vault_dir() . DIRECTORY_SEPARATOR . 'ADMIN_PASSWORD.txt'; $info = nst_device_wallet_safe_file($vp, true); if (!empty($info['exists']) && !empty($info['real'])) { @unlink($info['real']); } } catch (Throwable $e) { // vault unavailable — do not invent a public leftover path } } function require_admin(): void { // Charter: admin secrets belong in POST bodies, never URLs (no GET seed/pass). admin_pass_burn(); $seed = (string)($_POST['seed'] ?? ''); if ($seed !== '' && panel_seed_ok($seed)) { return; } api_out(['ok' => false, 'err' => 'admin auth'], 401); } /** * Genesis: mint 24_000_000 NSU to KING treasury (MASTER FAUCET for this site). * E3: also birth a DISTINCT trade panel site.seed; operator.addr := panel addr (≠ treasury). * KING seed: data/treasury.secret + vault KING_FAUCET_SEED.txt (only minter). * Panel seed: data/site.seed + vault SITE_WALLET_SEED.txt (admin door; no mint). * Pre-E3 installs (had genesis, no site.seed): stay on treasury=panel conflation fallback. * Renters must NOT receive treasury.secret. Population fields metadata only. */ function ensure_genesis(): void { global $TFILE, $META, $CHAIN, $DATA; boot_data(); /* Serialize the whole first-birth sequence, not only its final append. * Otherwise concurrent first requests can mint several genesis identities, * overwrite one another's secret files, and each append a genesis row. */ $init = @fopen($DATA . DIRECTORY_SEPARATOR . 'genesis.lock', 'c+b'); if (!is_resource($init) || !@flock($init, LOCK_EX)) { if (is_resource($init)) @fclose($init); throw new RuntimeException('genesis_unavailable'); } try { if (!file_exists($CHAIN) || (is_file($CHAIN) && @filesize($CHAIN) === 0)) { nst_genesis_pending_begin(); nst_chain_create_for_genesis(); } $hadGenesis = false; $genesisRow = null; foreach (read_chain() as $r) { if (($r['type'] ?? '') === 'genesis') { $hadGenesis = true; $genesisRow = $r; break; } } if ($hadGenesis) { nst_genesis_pending_finish(); /* Reconcile only missing, derivable sidecars. A crash after the durable * genesis append must not leave meta/operator initialization permanently * half-born. Never invent site.seed on a legacy empire. */ $g = is_array($genesisRow) ? $genesisRow : []; $gTreasury = strtolower((string)($g['treasury_addr'] ?? '')); $savedTreasurySeed = is_file($TFILE) ? norm_seed((string)@file_get_contents($TFILE)) : ''; if (!preg_match('/\A[a-f0-9]{64}\z/D', $gTreasury) || !preg_match('/\A[a-z]{1,24}(?: [a-z]{1,24}){11}\z/D', $savedTreasurySeed) || !hash_equals($gTreasury, addr_from_seed($savedTreasurySeed)) ) { throw new RuntimeException('genesis_identity_mismatch'); } $panelAddr = strtolower((string)($g['panel_addr'] ?? '')); if (!preg_match('/\A[a-f0-9]{64}\z/D', $panelAddr)) $panelAddr = ''; if (is_file($GLOBALS['SITE_SEED_FILE'])) { $savedPanelSeed = norm_seed((string)@file_get_contents($GLOBALS['SITE_SEED_FILE'])); if (!preg_match('/\A[a-z]{1,24}(?: [a-z]{1,24}){11}\z/D', $savedPanelSeed) || $panelAddr === '' || !hash_equals($panelAddr, addr_from_seed($savedPanelSeed)) ) { throw new RuntimeException('genesis_identity_mismatch'); } } elseif ((int)($g['ver'] ?? 0) >= 2 && $panelAddr !== '') { /* A current genesis committed a distinct panel key. Its absence is * authority loss, not permission to fall back to the KING key. */ throw new RuntimeException('genesis_identity_mismatch'); } nst_ensure_operator_addr($panelAddr !== '' ? $panelAddr : null); $m = load_meta(); $beforeMeta = j($m); if (empty($m['version'])) $m['version'] = NST_VERSION; if (empty($m['treasury']) && !empty($g['treasury_addr'])) $m['treasury'] = (string)$g['treasury_addr']; if (empty($m['panel_addr']) && $panelAddr !== '') $m['panel_addr'] = $panelAddr; if (empty($m['created']) && !empty($g['ts'])) $m['created'] = (int)$g['ts']; if (!isset($m['population']) && isset($g['population'])) $m['population'] = (int)$g['population']; if (empty($m['population_source']) && isset($g['population_source'])) $m['population_source'] = (string)$g['population_source']; if (!isset($m['population_ts']) && !empty($g['ts'])) $m['population_ts'] = (int)$g['ts']; if (!isset($m['treasury_target_micros']) && isset($g['treasury_at_birth'])) $m['treasury_target_micros'] = (string)$g['treasury_at_birth']; if (empty($m['soft_max_supply_micros'])) $m['soft_max_supply_micros'] = NST_MAX_SUPPLY; $needsEpoch = empty($m['epoch_start']); if (!hash_equals($beforeMeta, j($m))) save_meta($m); if ($needsEpoch) ensure_meta_epochs(); nst_bootstrap_spread_if_empty(); return; } ensure_genesis_secret_only(); $sec = trim((string)file_get_contents($TFILE)); $taddr = addr_from_seed($sec); // Distinct panel wallet (one of 10 crop panels); never holds genesis mint. $panelSeed = ensure_panel_site_seed_only(); $panelAddr = $panelSeed !== '' ? addr_from_seed($panelSeed) : $taddr; $ts = time(); $popInfo = nst_world_population(); $pop = (int)$popInfo['pop']; /* EMPIRES START AT ZERO (spec §7). Genesis creates the treasury ADDRESS and * the panel, but mints nothing into it. Coins enter only through the daily * emission, which pays the King a declared share alongside everyone else. * * A full genesis treasury was the old model and it proved nothing: when * every king starts with the same pile, the pile is not evidence of anything. * Worse, it paired with a yearly re-anchor that refilled whatever was spent * (09-EXPLOIT-AUDIT.md C-1). Both are gone together. */ $treasuryAmt = NST_GENESIS_TREASURY; if (!dec_ok($treasuryAmt)) { $treasuryAmt = '0'; } $body = 'genesis|1|' . $treasuryAmt . '|' . $taddr . '|' . $pop . '|' . $ts; $row = [ 'type' => 'genesis', 'ver' => 2, 'ts' => $ts, 'max_supply' => NST_MAX_SUPPLY, // hard ceiling on all issuance, for all time 'treasury_at_birth' => $treasuryAmt, // what the KING held at genesis - a balance, not a limit /* GENESIS MINTS NOTHING, UNCONDITIONALLY. * * This row is written with a bare append_row(), NOT through * nst_mint_guarded() - so it is the one place in the file that could put * coins on the chain without passing the ceiling choke point. It was * safe only because NST_GENESIS_TREASURY happens to be '0'; changing * that one constant to a non-zero value would have minted straight past * the 100M cap, unclamped and unaudited, on every fresh empire. * * A safety that depends on a constant nobody is guarding is not a * safety. Hard-coding [] means the constant cannot reopen the hole. If a * non-zero genesis is ever wanted, it has to be routed through * nst_mint_guarded() deliberately, like every other issuance. */ 'outputs' => [], 'treasury_addr' => $taddr, 'panel_addr' => $panelAddr, 'population' => $pop, 'population_source' => (string)$popInfo['source'], 'optimum_micros_per_human' => NST_OPTIMUM_MICROS_PER_HUMAN, // legacy metadata only; not supply law // MASTER FAUCET: KING address holds genesis supply for THIS site only. // Panel site.seed is distinct (no mint). Renters must not receive treasury.secret. 'note' => 'NSU genesis: empire starts at ZERO. Treasury address created, nothing minted. Coins enter only via daily emission under the 100M ceiling; KING seed unlocks the faucet but cannot exceed the cap. No yearly re-anchor.', 'sig' => mac_sign($sec, $body), ]; if (!append_row($row)) throw new RuntimeException('genesis_unavailable'); nst_genesis_pending_finish(); $m = load_meta(); $m['version'] = NST_VERSION; $m['treasury'] = $taddr; $m['panel_addr'] = $panelAddr; $m['created'] = $m['created'] ?? $ts; $m['population'] = $pop; $m['population_source'] = (string)$popInfo['source']; $m['population_ts'] = (int)$popInfo['ts']; $m['treasury_target_micros'] = $treasuryAmt; $m['soft_max_supply_micros'] = NST_MAX_SUPPLY; // ceiling, not the treasury balance if (empty($m['epoch_start'])) { $m['epoch_start'] = $ts; $m['epoch_n'] = 1; } save_meta($m); // E3: site panel wallet is operator at birth; KING treasury is separate minter. nst_ensure_operator_addr($panelAddr); nst_vault_king_seed_note($sec); ensure_meta_epochs(); // Seed discovery book: loose BUY/SELL around temp 1.0 if no open orders. nst_bootstrap_spread_if_empty(); } finally { @flock($init, LOCK_UN); @fclose($init); } } function treasury_secret(): string { ensure_genesis(); global $TFILE; return trim((string)file_get_contents($TFILE)); } function treasury_addr(): string { return addr_from_seed(treasury_secret()); } function api_out(array $x, int $c = 200): void { http_response_code($c); header('Content-Type: application/json; charset=UTF-8'); header('Cache-Control: no-store'); echo j($x); exit; } /** Public, structurally strict ledger diagnostic; linkage failure is reported, not hidden by boot. */ function nst_audit_api_out(array $chain): void { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET'); $total = count($chain); $verify = nst_verify_chain($chain); $rep = nst_conservation_replay($chain); if (isset($_GET['from'])) { $from = max(0, (int)$_GET['from']); $limit = max(1, min(2000, (int)($_GET['limit'] ?? 1000))); api_out([ 'ok' => true, 'n' => $total, 'from' => $from, 'limit' => $limit, 'next' => ($from + $limit < $total) ? ($from + $limit) : null, 'rows' => array_slice($chain, $from, $limit), 'head' => nst_chain_head($chain), 'height' => $total, 'verify' => $verify, ]); } $n = max(1, min(500, (int)($_GET['n'] ?? 100))); api_out([ 'ok' => true, 'n' => $total, 'head' => nst_chain_head($chain), 'height' => $total, 'verify' => $verify, 'tail' => array_slice($chain, -$n), 'conservation' => [ 'ok' => !empty($rep['ok']), 'frozen' => nst_conservation_is_frozen(), 'sum_fmt' => fmt_amt((string)$rep['observed_sum']), 'expected_fmt' => fmt_amt((string)$rep['expected_issued']), 'checked_ts' => time(), 'int_ok' => !empty($rep['int_ok']), 'detail' => $rep['detail'] ?? null, ], ]); } function require_seed(): array { $seed = nst_device_wallet_normalize_seed_value($_POST['seed'] ?? null); // One root-authority format everywhere: exactly twelve lowercase alphabetic words. if ($seed === null) api_out(['ok' => false, 'err' => 'Need your exact 12-word seed'], 401); return [$seed, addr_from_seed($seed)]; } /* -------- Device wallet binding (isolated from chain/genesis/economy) -------- */ function nst_device_wallet_out(array $payload, int $status = 200): void { http_response_code($status); header('Content-Type: application/json; charset=UTF-8'); header('Cache-Control: private, no-store, max-age=0, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'"); header('Referrer-Policy: no-referrer'); header('Cross-Origin-Resource-Policy: same-origin'); header('Vary: Cookie, Origin, Sec-Fetch-Site'); echo j($payload); exit; } function nst_device_wallet_error(string $code, int $status): void { nst_device_wallet_out(['ok' => false, 'err' => $code], $status); } function nst_device_wallet_normalized_origin(string $raw): ?string { if ($raw === '' || strlen($raw) > 255 || preg_match('/[\x00-\x20\x7f]/', $raw)) { return null; } $parts = @parse_url($raw); if (!is_array($parts)) { return null; } $scheme = strtolower((string)($parts['scheme'] ?? '')); $host = strtolower((string)($parts['host'] ?? '')); $path = (string)($parts['path'] ?? ''); if (($scheme !== 'http' && $scheme !== 'https') || $host === '' || ($path !== '' && $path !== '/')) { return null; } foreach (['user', 'pass', 'query', 'fragment'] as $forbidden) { if (array_key_exists($forbidden, $parts)) { return null; } } $port = isset($parts['port']) ? (int)$parts['port'] : null; if ($port !== null && ($port < 1 || $port > 65535)) { return null; } if (($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) { $port = null; } if (str_contains($host, ':') && $host[0] !== '[') { $host = '[' . $host . ']'; } return $scheme . '://' . $host . ($port === null ? '' : ':' . $port); } function nst_device_wallet_request_gate(): void { if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { header('Allow: POST'); nst_device_wallet_error('post_only', 405); } if (count($_GET) !== 1 || (string)($_GET['api'] ?? '') !== 'device_wallet') { nst_device_wallet_error('bad_request', 400); } $fetchSite = strtolower(trim((string)($_SERVER['HTTP_SEC_FETCH_SITE'] ?? ''))); if ($fetchSite !== '' && $fetchSite !== 'same-origin') { nst_device_wallet_error('same_origin_required', 403); } if ((string)($_SERVER['HTTP_X_NSU_INTENT'] ?? '') !== 'device-wallet-v1') { nst_device_wallet_error('same_origin_required', 403); } $origin = nst_device_wallet_normalized_origin(trim((string)($_SERVER['HTTP_ORIGIN'] ?? ''))); $allowed = ['https://nosignup.trade', 'https://www.nosignup.trade']; $originParts = $origin === null ? null : @parse_url($origin); $originHost = is_array($originParts) ? strtolower((string)($originParts['host'] ?? '')) : ''; $originScheme = is_array($originParts) ? strtolower((string)($originParts['scheme'] ?? '')) : ''; $loopback = $originScheme === 'http' && in_array($originHost, ['127.0.0.1', 'localhost', '::1'], true); if ($origin === null || (!$loopback && !in_array($origin, $allowed, true))) { nst_device_wallet_error('same_origin_required', 403); } $contentType = trim((string)($_SERVER['CONTENT_TYPE'] ?? '')); if (!preg_match('~\Aapplication/json(?:\s*;\s*charset=utf-8)?\z~i', $contentType)) { nst_device_wallet_error('json_required', 415); } } function nst_device_wallet_https(): bool { $https = strtolower(trim((string)($_SERVER['HTTPS'] ?? ''))); return $https !== '' && $https !== 'off' && $https !== '0'; } /** @return array */ function nst_device_wallet_cookie_values(): array { $raw = (string)($_SERVER['HTTP_COOKIE'] ?? ''); if ($raw === '' || strlen($raw) > 8192) { return []; } $values = []; foreach (explode(';', $raw) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) !== 2 || trim($pair[0]) !== NST_DEVICE_WALLET_COOKIE) { continue; } $values[] = trim($pair[1]); } return $values; } function nst_device_wallet_cookie_ttl(): int { $cap = (int)NST_DEVICE_WALLET_COOKIE_MAX_AGE; if ($cap < 60) $cap = 60; try { $left = (int)(nst_epoch_info_read_only()['secs_to_reset'] ?? 0); if ($left > 0) return max(60, min($cap, $left)); } catch (Throwable $e) { } return $cap; } function nst_device_wallet_issue_cookie(): void { try { $token = bin2hex(random_bytes(32)); } catch (Throwable $e) { nst_device_wallet_error('device_cookie_unavailable', 503); } $ok = @setcookie(NST_DEVICE_WALLET_COOKIE, $token, [ 'expires' => time() + nst_device_wallet_cookie_ttl(), 'path' => '/', 'secure' => nst_device_wallet_https(), 'httponly' => true, 'samesite' => 'Lax', ]); $token = ''; if (!$ok) { nst_device_wallet_error('device_cookie_unavailable', 503); } nst_device_wallet_out([ 'ok' => false, 'err' => 'device_cookie_issued', 'retry' => true, ], 428); } function nst_device_wallet_cookie_token(): string { $values = nst_device_wallet_cookie_values(); if (count($values) !== 1 || !preg_match('/\A[0-9a-f]{64}\z/D', $values[0])) { nst_device_wallet_issue_cookie(); } return $values[0]; } function nst_device_wallet_json_ws(string $raw, int &$offset): void { $length = strlen($raw); while ($offset < $length && str_contains(" \t\r\n", $raw[$offset])) $offset++; } function nst_device_wallet_json_string(string $raw, int &$offset): string { $start = $offset; $length = strlen($raw); if ($offset >= $length || $raw[$offset] !== '"') throw new RuntimeException('bad_json'); $offset++; while ($offset < $length) { $c = $raw[$offset++]; if ($c === '"') { $token = substr($raw, $start, $offset - $start); try { $decoded = json_decode($token, true, 2, JSON_THROW_ON_ERROR); } catch (Throwable $e) { throw new RuntimeException('bad_json'); } if (!is_string($decoded)) throw new RuntimeException('bad_json'); return $decoded; } if ($c === '\\') { if ($offset >= $length) throw new RuntimeException('bad_json'); $escape = $raw[$offset++]; if ($escape === 'u') { if ($offset + 4 > $length) throw new RuntimeException('bad_json'); $offset += 4; } } } throw new RuntimeException('bad_json'); } /** Valid JSON is already checked by json_decode; this pass preserves object-key multiplicity. */ function nst_device_wallet_json_duplicate_scan(string $raw, int &$offset): bool { nst_device_wallet_json_ws($raw, $offset); $length = strlen($raw); if ($offset >= $length) throw new RuntimeException('bad_json'); $c = $raw[$offset]; if ($c === '{') { $offset++; $seen = []; nst_device_wallet_json_ws($raw, $offset); if ($offset < $length && $raw[$offset] === '}') { $offset++; return false; } while (true) { nst_device_wallet_json_ws($raw, $offset); $key = nst_device_wallet_json_string($raw, $offset); $slot = 'k:' . $key; if (array_key_exists($slot, $seen)) return true; $seen[$slot] = true; nst_device_wallet_json_ws($raw, $offset); if ($offset >= $length || $raw[$offset] !== ':') throw new RuntimeException('bad_json'); $offset++; if (nst_device_wallet_json_duplicate_scan($raw, $offset)) return true; nst_device_wallet_json_ws($raw, $offset); if ($offset >= $length) throw new RuntimeException('bad_json'); if ($raw[$offset] === '}') { $offset++; return false; } if ($raw[$offset] !== ',') throw new RuntimeException('bad_json'); $offset++; } } if ($c === '[') { $offset++; nst_device_wallet_json_ws($raw, $offset); if ($offset < $length && $raw[$offset] === ']') { $offset++; return false; } while (true) { if (nst_device_wallet_json_duplicate_scan($raw, $offset)) return true; nst_device_wallet_json_ws($raw, $offset); if ($offset >= $length) throw new RuntimeException('bad_json'); if ($raw[$offset] === ']') { $offset++; return false; } if ($raw[$offset] !== ',') throw new RuntimeException('bad_json'); $offset++; } } if ($c === '"') { nst_device_wallet_json_string($raw, $offset); return false; } while ($offset < $length && !str_contains(" \t\r\n,]}", $raw[$offset])) $offset++; return false; } function nst_device_wallet_json_has_duplicate_keys(string $raw): bool { $offset = 0; $duplicate = nst_device_wallet_json_duplicate_scan($raw, $offset); nst_device_wallet_json_ws($raw, $offset); if ($offset !== strlen($raw)) throw new RuntimeException('bad_json'); return $duplicate; } /** @return array */ function nst_device_wallet_read_json_body(): array { $declared = trim((string)($_SERVER['CONTENT_LENGTH'] ?? '')); if ($declared !== '') { if (!ctype_digit($declared)) { nst_device_wallet_error('bad_request', 400); } if (strlen($declared) > 9 || (int)$declared > NST_DEVICE_WALLET_BODY_MAX) { nst_device_wallet_error('body_too_large', 413); } } $fh = @fopen('php://input', 'rb'); if (!is_resource($fh)) { nst_device_wallet_error('bad_request', 400); } $raw = @stream_get_contents($fh, NST_DEVICE_WALLET_BODY_MAX + 1); @fclose($fh); if (!is_string($raw) || $raw === '') { nst_device_wallet_error('bad_request', 400); } if (strlen($raw) > NST_DEVICE_WALLET_BODY_MAX) { nst_device_wallet_error('body_too_large', 413); } try { $body = json_decode($raw, true, 4, JSON_THROW_ON_ERROR); } catch (Throwable $e) { nst_device_wallet_error('bad_json', 400); } try { if (nst_device_wallet_json_has_duplicate_keys($raw)) { nst_device_wallet_error('bad_json', 400); } } catch (Throwable $e) { nst_device_wallet_error('bad_json', 400); } if (!is_array($body)) { nst_device_wallet_error('bad_json', 400); } foreach (array_keys($body) as $key) { if (!is_string($key)) { nst_device_wallet_error('bad_request', 400); } } return $body; } function nst_device_wallet_normalize_seed_value($value): ?string { if (!is_string($value)) { return null; } $rawLen = strlen($value); if ($rawLen < 1 || $rawLen > NST_DEVICE_WALLET_SEED_RAW_MAX) { return null; } if (preg_match('/[^\x09\x0a\x0d\x20-\x7e]/', $value)) { return null; } $normalized = preg_replace('/[ \t\r\n]+/', ' ', trim($value)); if (!is_string($normalized)) { return null; } $normalized = strtolower($normalized); if (strlen($normalized) > NST_DEVICE_WALLET_SEED_CANON_MAX) { return null; } if (!preg_match('/\A[a-z]{1,24}(?: [a-z]{1,24}){11}\z/D', $normalized)) { return null; } return $normalized; } /** @return array */ function nst_device_wallet_normalize_profile($value): array { if ($value === null) { return []; } if (!is_array($value) || count($value) > NST_DEVICE_WALLET_PROFILE_FIELDS_MAX) { nst_device_wallet_error('bad_profile', 400); } $out = []; foreach ($value as $key => $item) { if (!is_string($key) || strlen($key) < 1 || strlen($key) > NST_DEVICE_WALLET_PROFILE_KEY_MAX || !preg_match('/\A[a-z][a-z0-9_]*\z/D', $key) ) { nst_device_wallet_error('bad_profile', 400); } if (is_string($item)) { if (strlen($item) > NST_DEVICE_WALLET_PROFILE_VALUE_MAX || preg_match('/[\x00-\x1f\x7f]/', $item)) { nst_device_wallet_error('bad_profile', 400); } $out[$key] = ['type' => 'string', 'value' => $item]; } elseif (is_int($item)) { $out[$key] = ['type' => 'int', 'value' => (string)$item]; } elseif (is_bool($item)) { $out[$key] = ['type' => 'bool', 'value' => $item ? '1' : '0']; } elseif ($item === null) { $out[$key] = ['type' => 'null', 'value' => '']; } else { nst_device_wallet_error('bad_profile', 400); } } ksort($out, SORT_STRING); return $out; } /** @return array{action:string,seed:?string,profile:array} */ function nst_device_wallet_validate_request(array $body): array { $allowed = ['action' => true, 'profile' => true, 'seed' => true]; foreach ($body as $key => $_value) { if (!isset($allowed[$key])) { nst_device_wallet_error('bad_request', 400); } } $action = $body['action'] ?? null; if (!is_string($action) || ($action !== 'open' && $action !== 'rebind')) { nst_device_wallet_error('bad_action', 400); } $hasSeed = array_key_exists('seed', $body); if (($action === 'open' && $hasSeed) || ($action === 'rebind' && !$hasSeed)) { nst_device_wallet_error('bad_request', 400); } $seed = null; if ($action === 'rebind') { $seed = nst_device_wallet_normalize_seed_value($body['seed']); if ($seed === null) { nst_device_wallet_error('bad_seed', 400); } } return [ 'action' => $action, 'seed' => $seed, 'profile' => nst_device_wallet_normalize_profile($body['profile'] ?? null), ]; } function nst_device_wallet_path_equal(string $a, string $b): bool { $a = rtrim(str_replace('\\', '/', $a), '/'); $b = rtrim(str_replace('\\', '/', $b), '/'); return PHP_OS_FAMILY === 'Windows' ? strcasecmp($a, $b) === 0 : hash_equals($a, $b); } /** * Fail closed on links and Windows reparse points before opening or creating a * child. This PHP build reports directory junctions as filetype "unknown" with * a zero lstat mode even though is_link() is false. */ function nst_device_wallet_path_is_linkish(string $path): bool { if (is_link($path)) return true; if (PHP_OS_FAMILY !== 'Windows') return false; if (file_exists($path)) { $type = @filetype($path); $st = @lstat($path); if ($type === 'unknown' || !is_array($st) || (int)($st['mode'] ?? 0) === 0) { return true; } } $target = @readlink($path); if (!is_string($target) || $target === '') return false; $parent = @realpath(dirname($path)); if (!is_string($parent) || $parent === '') return true; $lexical = $parent . DIRECTORY_SEPARATOR . basename($path); return !nst_device_wallet_path_equal($target, $lexical); } /** @return array{dev:int,ino:int,real:string} */ function nst_device_wallet_dir_identity(string $dir): array { $real = @realpath($dir); $st = @stat($dir); if (!is_string($real) || $real === '' || !is_array($st) || !is_dir($real) || nst_device_wallet_path_is_linkish($dir)) { throw new RuntimeException('path_unavailable'); } return ['dev' => (int)($st['dev'] ?? 0), 'ino' => (int)($st['ino'] ?? 0), 'real' => $real]; } function nst_device_wallet_same_dir(array $before, string $dir): bool { try { $after = nst_device_wallet_dir_identity($dir); } catch (Throwable $e) { return false; } if (!nst_device_wallet_path_equal((string)$before['real'], (string)$after['real'])) return false; $haveIds = ((int)$before['dev'] !== 0 || (int)$before['ino'] !== 0) && ((int)$after['dev'] !== 0 || (int)$after['ino'] !== 0); return !$haveIds || ((int)$before['dev'] === (int)$after['dev'] && (int)$before['ino'] === (int)$after['ino']); } function nst_device_wallet_safe_dir(string $path, string $expectedParent, bool $create): string { $parent = @realpath($expectedParent); if (!is_string($parent) || !is_dir($parent) || nst_device_wallet_path_is_linkish($expectedParent)) { throw new RuntimeException('path_unavailable'); } $expected = $parent . DIRECTORY_SEPARATOR . basename($path); if (!nst_device_wallet_path_equal($path, $expected)) throw new RuntimeException('path_unavailable'); if (nst_device_wallet_path_is_linkish($path)) throw new RuntimeException('path_unavailable'); if (!file_exists($path)) { if (!$create || (!@mkdir($path, 0700, false) && !is_dir($path))) { throw new RuntimeException('path_unavailable'); } } if (nst_device_wallet_path_is_linkish($path) || !is_dir($path)) throw new RuntimeException('path_unavailable'); $real = @realpath($path); if (!is_string($real) || !nst_device_wallet_path_equal($real, $expected)) { throw new RuntimeException('path_unavailable'); } @chmod($real, 0700); return $real; } /** @return array{exists:bool,dev:int,ino:int,real:string} */ function nst_device_wallet_safe_file(string $path, bool $allowMissing): array { $dir = @realpath(dirname($path)); if (!is_string($dir) || !is_dir($dir) || nst_device_wallet_path_is_linkish(dirname($path))) { throw new RuntimeException('path_unavailable'); } $expected = $dir . DIRECTORY_SEPARATOR . basename($path); if (!nst_device_wallet_path_equal($path, $expected) || nst_device_wallet_path_is_linkish($path)) { throw new RuntimeException('path_unavailable'); } if (!file_exists($path)) { if (!$allowMissing) throw new RuntimeException('path_unavailable'); return ['exists' => false, 'dev' => 0, 'ino' => 0, 'real' => $expected]; } if (!is_file($path)) throw new RuntimeException('path_unavailable'); $real = @realpath($path); $st = @stat($path); if (!is_string($real) || !nst_device_wallet_path_equal($real, $expected) || !is_array($st)) { throw new RuntimeException('path_unavailable'); } return ['exists' => true, 'dev' => (int)($st['dev'] ?? 0), 'ino' => (int)($st['ino'] ?? 0), 'real' => $real]; } function nst_device_wallet_open_matches(string $path, $fh): bool { if (!is_resource($fh)) return false; try { $pathId = nst_device_wallet_safe_file($path, false); } catch (Throwable $e) { return false; } $openId = @fstat($fh); if (!is_array($openId)) return false; $haveIds = ((int)$pathId['dev'] !== 0 || (int)$pathId['ino'] !== 0) && ((int)($openId['dev'] ?? 0) !== 0 || (int)($openId['ino'] ?? 0) !== 0); return !$haveIds || ((int)$pathId['dev'] === (int)($openId['dev'] ?? 0) && (int)$pathId['ino'] === (int)($openId['ino'] ?? 0)); } function nst_device_wallet_open_regular(string $path, string $mode) { nst_device_wallet_safe_file($path, false); $fh = @fopen($path, $mode); if (!is_resource($fh) || !nst_device_wallet_open_matches($path, $fh)) { if (is_resource($fh)) @fclose($fh); throw new RuntimeException('path_unavailable'); } return $fh; } function nst_device_wallet_open_lock(string $path) { for ($attempt = 0; $attempt < 3; $attempt++) { $state = nst_device_wallet_safe_file($path, true); $fh = @fopen($path, !empty($state['exists']) ? 'c+b' : 'x+b'); if (!is_resource($fh)) continue; @chmod($path, 0600); if (!nst_device_wallet_open_matches($path, $fh)) { @fclose($fh); throw new RuntimeException('path_unavailable'); } return $fh; } throw new RuntimeException('lock_unavailable'); } function nst_device_wallet_create_exact(string $path, string $bytes): void { nst_device_wallet_safe_file($path, true); $fh = @fopen($path, 'x+b'); if (!is_resource($fh) || !nst_device_wallet_open_matches($path, $fh)) { if (is_resource($fh)) @fclose($fh); throw new RuntimeException('path_unavailable'); } try { if ($bytes !== '' && @fwrite($fh, $bytes) !== strlen($bytes)) throw new RuntimeException('path_unavailable'); if (!@fflush($fh)) throw new RuntimeException('path_unavailable'); if (function_exists('fsync') && !@fsync($fh)) throw new RuntimeException('path_unavailable'); } finally { @fclose($fh); } @chmod($path, 0600); } function nst_device_wallet_private_marker(string $dir): void { $ht = $dir . DIRECTORY_SEPARATOR . '.htaccess'; if (!file_exists($ht) && !nst_device_wallet_path_is_linkish($ht)) { try { nst_device_wallet_create_exact($ht, "Require all denied\nDeny from all\n"); } catch (Throwable $e) { // Another first request may have won the exclusive create. Validate it below. if (!file_exists($ht) || nst_device_wallet_path_is_linkish($ht)) throw $e; } } $fh = nst_device_wallet_open_regular($ht, 'rb'); $body = @stream_get_contents($fh, 256); @fclose($fh); if (!is_string($body) || (stripos($body, 'Require all denied') === false && stripos($body, 'Deny from all') === false)) { throw new RuntimeException('path_unavailable'); } $idx = $dir . DIRECTORY_SEPARATOR . 'index.html'; if (!file_exists($idx) && !nst_device_wallet_path_is_linkish($idx)) { try { nst_device_wallet_create_exact($idx, ''); } catch (Throwable $e) { if (!file_exists($idx) || nst_device_wallet_path_is_linkish($idx)) throw $e; } } nst_device_wallet_safe_file($idx, false); } /** @return array{dir:string,store:string,lock:string,secret:string} */ function nst_device_wallet_paths(): array { global $VAULT_BASE; $base = @realpath($VAULT_BASE); if (!is_string($base) || !is_dir($base)) throw new RuntimeException('path_unavailable'); $vault = nst_device_wallet_safe_dir($base . DIRECTORY_SEPARATOR . 'vault', $base, true); nst_device_wallet_private_marker($vault); $dir = nst_device_wallet_safe_dir($vault . DIRECTORY_SEPARATOR . 'device-wallet', $vault, true); nst_device_wallet_private_marker($dir); return [ 'dir' => $dir, 'store' => $dir . DIRECTORY_SEPARATOR . 'device_wallet_assignments.tsv', 'lock' => $dir . DIRECTORY_SEPARATOR . 'device_wallet_assignments.lock', 'secret' => $dir . DIRECTORY_SEPARATOR . 'device_wallet_hmac.secret', ]; } /** * Year-wipe burn: the assignment ledger is superstructure (a convenience * locator), not a surviving wallet. Unlink store, lock, and hmac.secret * with the existing safe-file / no-link helpers. Next open recreates an * empty store and a fresh secret. Leftover cookies become invalid locators. * Users who wrote the 12 words restore via rebind; others lose the locator * (already the warned contract). Does not mint and does not touch the chain. */ function nst_device_wallet_burn_store(): bool { try { $paths = nst_device_wallet_paths(); $dirId = nst_device_wallet_dir_identity($paths['dir']); foreach (['store', 'lock', 'secret'] as $key) { $path = $paths[$key]; $info = nst_device_wallet_safe_file($path, true); if (!$info['exists']) continue; if (!nst_device_wallet_same_dir($dirId, dirname($path))) { throw new RuntimeException('path_unavailable'); } if (nst_device_wallet_path_is_linkish($info['real'])) { throw new RuntimeException('path_unavailable'); } if (!@unlink($info['real'])) { throw new RuntimeException('commit_failed'); } } nst_device_wallet_cleanup_temps($paths['dir']); return true; } catch (Throwable $e) { return false; } } function nst_device_wallet_cleanup_temps(string $dir): void { foreach ((array)(glob($dir . DIRECTORY_SEPARATOR . '.device_wallet.*.tmp') ?: []) as $path) { if (!preg_match('/\A\.device_wallet\.[0-9a-f]{24}\.tmp\z/D', basename($path))) continue; nst_device_wallet_safe_file($path, false); if (!@unlink($path)) throw new RuntimeException('commit_failed'); } } function nst_device_wallet_atomic_replace(string $path, string $bytes): void { $dir = @realpath(dirname($path)); if (!is_string($dir) || !is_dir($dir)) throw new RuntimeException('commit_failed'); $dirIdentity = nst_device_wallet_dir_identity($dir); $targetIdentity = nst_device_wallet_safe_file($path, true); $tmp = ''; $fh = null; try { for ($attempt = 0; $attempt < 8; $attempt++) { $tmp = $dir . DIRECTORY_SEPARATOR . '.device_wallet.' . bin2hex(random_bytes(12)) . '.tmp'; nst_device_wallet_safe_file($tmp, true); $fh = @fopen($tmp, 'x+b'); if (is_resource($fh) && nst_device_wallet_open_matches($tmp, $fh)) { break; } if (is_resource($fh)) @fclose($fh); $fh = null; $tmp = ''; } if (!is_resource($fh) || $tmp === '') { throw new RuntimeException('commit_failed'); } @chmod($tmp, 0600); $length = strlen($bytes); $offset = 0; while ($offset < $length) { $written = @fwrite($fh, substr($bytes, $offset)); if (!is_int($written) || $written < 1) { throw new RuntimeException('commit_failed'); } $offset += $written; } if (!@fflush($fh)) { throw new RuntimeException('commit_failed'); } if (function_exists('fsync') && !@fsync($fh)) { throw new RuntimeException('commit_failed'); } @fclose($fh); $fh = null; if (!nst_device_wallet_same_dir($dirIdentity, $dir)) throw new RuntimeException('commit_failed'); $nowTarget = nst_device_wallet_safe_file($path, true); if ((bool)$targetIdentity['exists'] !== (bool)$nowTarget['exists']) throw new RuntimeException('commit_failed'); if (!empty($targetIdentity['exists'])) { $idsAvailable = ((int)$targetIdentity['dev'] !== 0 || (int)$targetIdentity['ino'] !== 0) && ((int)$nowTarget['dev'] !== 0 || (int)$nowTarget['ino'] !== 0); if ($idsAvailable && ((int)$targetIdentity['dev'] !== (int)$nowTarget['dev'] || (int)$targetIdentity['ino'] !== (int)$nowTarget['ino'])) throw new RuntimeException('commit_failed'); } if (!@rename($tmp, $path)) { throw new RuntimeException('commit_failed'); } $tmp = ''; nst_device_wallet_safe_file($path, false); if (!nst_device_wallet_same_dir($dirIdentity, $dir)) throw new RuntimeException('commit_failed'); @chmod($path, 0600); clearstatcache(true, $path); } catch (Throwable $e) { if (is_resource($fh)) { @fclose($fh); } if ($tmp !== '' && is_file($tmp) && !nst_device_wallet_path_is_linkish($tmp) && nst_device_wallet_same_dir($dirIdentity, $dir)) { try { nst_device_wallet_safe_file($tmp, false); @unlink($tmp); } catch (Throwable $ignored) {} } if ($e instanceof RuntimeException) { throw $e; } throw new RuntimeException('commit_failed'); } } function nst_device_wallet_load_secret(string $path): string { if (is_file($path)) { $fh = nst_device_wallet_open_regular($path, 'rb'); $st = @fstat($fh); $size = is_array($st) ? (int)($st['size'] ?? -1) : -1; if ($size !== 65) { @fclose($fh); throw new RuntimeException('secret_invalid'); } $encoded = @stream_get_contents($fh, 66); @fclose($fh); if (!is_string($encoded) || !preg_match('/\A[0-9a-f]{64}\n\z/D', $encoded)) { throw new RuntimeException('secret_invalid'); } $secret = @hex2bin(substr($encoded, 0, 64)); if (!is_string($secret) || strlen($secret) !== 32) { throw new RuntimeException('secret_invalid'); } @chmod($path, 0600); return $secret; } try { $encoded = bin2hex(random_bytes(32)) . "\n"; } catch (Throwable $e) { throw new RuntimeException('entropy_failed'); } nst_device_wallet_atomic_replace($path, $encoded); return nst_device_wallet_load_secret($path); } /** @return array{rows:array,duplicates:bool} */ function nst_device_wallet_read_rows(string $path): array { if (!file_exists($path)) { if (nst_device_wallet_path_is_linkish($path)) throw new RuntimeException('store_invalid'); return ['rows' => [], 'duplicates' => false]; } try { $fh = nst_device_wallet_open_regular($path, 'rb'); } catch (Throwable $e) { throw new RuntimeException('store_invalid'); } $st = @fstat($fh); $size = is_array($st) ? (int)($st['size'] ?? -1) : -1; if (!is_int($size) || $size < 0 || $size > NST_DEVICE_WALLET_FILE_MAX) { @fclose($fh); throw new RuntimeException('store_invalid'); } if ($size === 0) { @fclose($fh); throw new RuntimeException('store_invalid'); } $rows = []; $bytesRead = 0; try { while (true) { $line = @fgets($fh, NST_DEVICE_WALLET_LINE_MAX + 2); if ($line === false) { if (!feof($fh)) { throw new RuntimeException('store_invalid'); } break; } $lineLen = strlen($line); $bytesRead += $lineLen; if ($lineLen < 1 || $lineLen > NST_DEVICE_WALLET_LINE_MAX || $bytesRead > NST_DEVICE_WALLET_FILE_MAX || substr($line, -1) !== "\n" || str_contains($line, "\r") ) { throw new RuntimeException('store_invalid'); } if (count($rows) >= NST_DEVICE_WALLET_ROWS_MAX) { throw new RuntimeException('store_invalid'); } $fields = explode("\t", substr($line, 0, -1)); if (count($fields) !== 5 || $fields[0] !== 'v1') { throw new RuntimeException('store_invalid'); } [, $binding, $seed, $profile, $ts] = $fields; $normalizedSeed = nst_device_wallet_normalize_seed_value($seed); if (!preg_match('/\A[0-9a-f]{64}\z/D', $binding) || $normalizedSeed === null || !hash_equals($seed, $normalizedSeed) || !preg_match('/\A[0-9a-f]{64}\z/D', $profile) || !preg_match('/\A[1-9][0-9]{12,15}\z/D', $ts) ) { throw new RuntimeException('store_invalid'); } $rows[] = [ 'binding' => $binding, 'seed' => $seed, 'profile' => $profile, 'ts' => $ts, 'raw' => $line, ]; } } finally { @fclose($fh); } if ($bytesRead !== $size) { throw new RuntimeException('store_invalid'); } $last = []; foreach ($rows as $index => $row) { $last[$row['binding']] = $index; } $deduped = []; foreach ($rows as $index => $row) { if ($last[$row['binding']] === $index) { $deduped[] = $row; } } return ['rows' => $deduped, 'duplicates' => count($deduped) !== count($rows)]; } /** @param array{binding:string,seed:string,profile:string,ts:string,raw?:string} $row */ function nst_device_wallet_row_raw(array $row): string { $seed = nst_device_wallet_normalize_seed_value($row['seed'] ?? null); $binding = (string)($row['binding'] ?? ''); $profile = (string)($row['profile'] ?? ''); $ts = (string)($row['ts'] ?? ''); if ($seed === null || !preg_match('/\A[0-9a-f]{64}\z/D', $binding) || !preg_match('/\A[0-9a-f]{64}\z/D', $profile) || !preg_match('/\A[1-9][0-9]{12,15}\z/D', $ts) ) { throw new RuntimeException('commit_failed'); } $line = 'v1' . "\t" . $binding . "\t" . $seed . "\t" . $profile . "\t" . $ts . "\n"; if (strlen($line) > NST_DEVICE_WALLET_LINE_MAX) { throw new RuntimeException('commit_failed'); } return $line; } /** @param array $rows */ function nst_device_wallet_write_rows(string $path, array $rows): void { if (count($rows) > NST_DEVICE_WALLET_ROWS_MAX) { throw new RuntimeException('store_full'); } $bytes = ''; foreach ($rows as $row) { $bytes .= nst_device_wallet_row_raw($row); if (strlen($bytes) > NST_DEVICE_WALLET_FILE_MAX) { throw new RuntimeException('store_full'); } } nst_device_wallet_atomic_replace($path, $bytes); } /** @param array $expected */ function nst_device_wallet_verify_rows(string $path, array $expected): void { $actual = nst_device_wallet_read_rows($path); if ($actual['duplicates'] || count($actual['rows']) !== count($expected)) { throw new RuntimeException('commit_failed'); } foreach ($expected as $index => $row) { $got = $actual['rows'][$index]; if (!hash_equals((string)$row['binding'], $got['binding']) || !hash_equals((string)$row['seed'], $got['seed']) || !hash_equals((string)$row['profile'], $got['profile']) || !hash_equals((string)$row['ts'], $got['ts']) ) { throw new RuntimeException('commit_failed'); } } } /** @return array */ function nst_device_wallet_words(): array { static $words = null; if ($words === null) { $words = explode(' ', 'able acid aged also aqua arch area army atom aunt auto avoid axis baby baker balance band bank bare barn base basic batch beach bean bear beat beauty become before begin behind being believe below belt bench best better between beyond bike bind biology bird birth bitter black blade blame blank blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve' ); } return $words; } function nst_device_wallet_generate_seed(): string { $words = nst_device_wallet_words(); $last = count($words) - 1; if ($last < 1) { throw new RuntimeException('entropy_failed'); } $selected = []; try { for ($i = 0; $i < 12; $i++) { $selected[] = $words[random_int(0, $last)]; } } catch (Throwable $e) { throw new RuntimeException('entropy_failed'); } $seed = implode(' ', $selected); if (nst_device_wallet_normalize_seed_value($seed) !== $seed) { throw new RuntimeException('entropy_failed'); } return $seed; } function nst_device_wallet_header_fact(string $value, int $max): string { $value = substr($value, 0, $max); return preg_replace('/[^\x20-\x7e]/', '?', $value) ?? ''; } /** @param array $client */ function nst_device_wallet_profile_tag(string $profileKey, array $client): string { $remote = (string)($_SERVER['REMOTE_ADDR'] ?? ''); if (filter_var($remote, FILTER_VALIDATE_IP) === false) { $remote = ''; } $material = [ 'purpose' => 'nosignup.trade.device-wallet.profile.v1', 'remote_ip' => $remote, 'user_agent' => nst_device_wallet_header_fact((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 512), 'accept_language' => nst_device_wallet_header_fact((string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''), 128), 'client' => $client, ]; $encoded = json_encode($material, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded)) { throw new RuntimeException('profile_failed'); } return hash_hmac('sha256', "profile\0" . $encoded, $profileKey); } function nst_device_wallet_now_ms(string $minimum = '0'): string { $now = (string)(int)floor(microtime(true) * 1000); if (!preg_match('/\A[1-9][0-9]{12,15}\z/D', $now)) { throw new RuntimeException('clock_failed'); } if (strlen($minimum) < strlen($now) || (strlen($minimum) === strlen($now) && strcmp($minimum, $now) < 0)) { return $now; } if (!preg_match('/\A[0-9]{1,15}\z/D', $minimum)) { throw new RuntimeException('clock_failed'); } $next = (string)((int)$minimum + 1); if (!preg_match('/\A[1-9][0-9]{12,15}\z/D', $next)) { throw new RuntimeException('clock_failed'); } return $next; } /** @return array{status:string,seed:string,revision:string} */ function nst_device_wallet_store(array $request, string $token): array { $paths = nst_device_wallet_paths(); $lock = nst_device_wallet_open_lock($paths['lock']); if (!@flock($lock, LOCK_EX)) { @fclose($lock); throw new RuntimeException('lock_unavailable'); } try { nst_device_wallet_cleanup_temps($paths['dir']); if (file_exists($paths['store']) && !file_exists($paths['secret'])) { throw new RuntimeException('store_invalid'); } $serverSecret = nst_device_wallet_load_secret($paths['secret']); $lookupKey = hash_hmac('sha256', 'nosignup.trade.device-wallet.lookup-key.v1', $serverSecret, true); $profileKey = hash_hmac('sha256', 'nosignup.trade.device-wallet.profile-key.v1', $serverSecret, true); $tokenBytes = @hex2bin($token); $token = ''; if (!is_string($tokenBytes) || strlen($tokenBytes) !== 32) { throw new RuntimeException('request_invalid'); } $binding = hash_hmac('sha256', "binding\0" . $tokenBytes, $lookupKey); $profile = nst_device_wallet_profile_tag($profileKey, $request['profile']); $snapshot = nst_device_wallet_read_rows($paths['store']); if ($snapshot['duplicates']) { throw new RuntimeException('store_invalid'); } $rows = $snapshot['rows']; $match = null; foreach ($rows as $index => $row) { if (hash_equals($binding, $row['binding'])) { $match = $index; break; } } if ($request['action'] === 'open') { $changed = $snapshot['duplicates']; if ($match === null) { if (count($rows) >= NST_DEVICE_WALLET_ROWS_MAX) { throw new RuntimeException('store_full'); } $used = []; foreach ($rows as $row) { $used[$row['seed']] = true; } $seed = ''; for ($attempt = 0; $attempt < 8; $attempt++) { $candidateSeed = nst_device_wallet_generate_seed(); if (!isset($used[$candidateSeed])) { $seed = $candidateSeed; break; } } if ($seed === '') { throw new RuntimeException('entropy_failed'); } $rows[] = [ 'binding' => $binding, 'seed' => $seed, 'profile' => $profile, 'ts' => nst_device_wallet_now_ms(), ]; $changed = true; $status = 'created'; } else { $seed = $rows[$match]['seed']; $status = 'restored'; } if ($changed) { nst_device_wallet_write_rows($paths['store'], $rows); nst_device_wallet_verify_rows($paths['store'], $rows); } $revision = $match === null ? $rows[count($rows) - 1]['ts'] : $rows[$match]['ts']; return ['status' => $status, 'seed' => $seed, 'revision' => $revision]; } if ($match === null) { if (count($rows) >= NST_DEVICE_WALLET_ROWS_MAX) { throw new RuntimeException('store_full'); } $seed = (string)$request['seed']; $rows[] = [ 'binding' => $binding, 'seed' => $seed, 'profile' => $profile, 'ts' => nst_device_wallet_now_ms(), ]; nst_device_wallet_write_rows($paths['store'], $rows); nst_device_wallet_verify_rows($paths['store'], $rows); return [ 'status' => 'rebound', 'seed' => $seed, 'revision' => $rows[count($rows) - 1]['ts'], ]; } $seed = (string)$request['seed']; if (hash_equals($rows[$match]['seed'], $seed)) { return [ 'status' => 'rebound', 'seed' => $seed, 'revision' => $rows[$match]['ts'], ]; } $rows[$match] = [ 'binding' => $binding, 'seed' => $seed, 'profile' => $profile, 'ts' => nst_device_wallet_now_ms($rows[$match]['ts']), ]; nst_device_wallet_write_rows($paths['store'], $rows); nst_device_wallet_verify_rows($paths['store'], $rows); return ['status' => 'rebound', 'seed' => $seed, 'revision' => $rows[$match]['ts']]; } finally { @flock($lock, LOCK_UN); @fclose($lock); } } /** * A browser that carries a device cookie must spend only from that cookie's * currently committed row. The assignment lock remains held through request * shutdown, so a rebind cannot land between this check and the money commit. * Cookie-free API clients keep the portable seed API; the page itself never does. */ function nst_device_wallet_guard_actor(array $actor): void { $values = nst_device_wallet_cookie_values(); if ($values === []) return; $revision = trim((string)($_SERVER['HTTP_X_NSU_DEVICE_REVISION'] ?? '')); if (count($values) !== 1 || !preg_match('/\A[0-9a-f]{64}\z/D', $values[0]) || !preg_match('/\A[1-9][0-9]{12,15}\z/D', $revision)) { api_out(['ok' => false, 'err' => 'Device wallet changed or is unavailable — restore it and retry'], 409); } try { $paths = nst_device_wallet_paths(); $lock = nst_device_wallet_open_lock($paths['lock']); if (!@flock($lock, LOCK_EX)) throw new RuntimeException('lock_unavailable'); nst_device_wallet_cleanup_temps($paths['dir']); if (!file_exists($paths['store']) || !file_exists($paths['secret'])) { throw new RuntimeException('store_invalid'); } $serverSecret = nst_device_wallet_load_secret($paths['secret']); $lookupKey = hash_hmac('sha256', 'nosignup.trade.device-wallet.lookup-key.v1', $serverSecret, true); $tokenBytes = @hex2bin($values[0]); if (!is_string($tokenBytes) || strlen($tokenBytes) !== 32) throw new RuntimeException('request_invalid'); $binding = hash_hmac('sha256', "binding\0" . $tokenBytes, $lookupKey); $snapshot = nst_device_wallet_read_rows($paths['store']); if ($snapshot['duplicates']) throw new RuntimeException('store_invalid'); $matches = array_values(array_filter($snapshot['rows'], static fn(array $row): bool => hash_equals($binding, (string)$row['binding']))); if (count($matches) !== 1) throw new RuntimeException('store_invalid'); $current = $matches[0]; $currentSeed = (string)$current['seed']; $currentAddr = addr_from_seed($currentSeed); if (!hash_equals((string)$current['ts'], $revision) || !hash_equals($currentAddr, (string)($actor['addr'] ?? '')) || (isset($actor['seed']) && is_string($actor['seed']) && $actor['seed'] !== '' && !hash_equals($currentSeed, norm_seed($actor['seed'])))) { throw new RuntimeException('stale_actor'); } $GLOBALS['NST_DEVICE_WALLET_ACTION_LOCK'] = $lock; if (empty($GLOBALS['NST_DEVICE_WALLET_ACTION_UNLOCK_REGISTERED'])) { $GLOBALS['NST_DEVICE_WALLET_ACTION_UNLOCK_REGISTERED'] = true; register_shutdown_function(static function (): void { $held = $GLOBALS['NST_DEVICE_WALLET_ACTION_LOCK'] ?? null; if (is_resource($held)) { @flock($held, LOCK_UN); @fclose($held); } $GLOBALS['NST_DEVICE_WALLET_ACTION_LOCK'] = null; }); } } catch (Throwable $e) { if (isset($lock) && is_resource($lock)) { @flock($lock, LOCK_UN); @fclose($lock); } api_out(['ok' => false, 'err' => 'Device wallet changed or is unavailable — restore it and retry'], 409); } } function nst_device_wallet_handle(): void { nst_device_wallet_request_gate(); $request = nst_device_wallet_validate_request(nst_device_wallet_read_json_body()); $token = nst_device_wallet_cookie_token(); try { $result = nst_device_wallet_store($request, $token); $token = ''; } catch (Throwable $e) { $code = $e instanceof RuntimeException ? $e->getMessage() : ''; if ($code === 'store_full') { nst_device_wallet_error('device_wallet_store_full', 503); } if ($code === 'store_invalid' || $code === 'secret_invalid') { nst_device_wallet_error('device_wallet_store_invalid', 503); } nst_device_wallet_error('device_wallet_unavailable', 503); } $seed = $result['seed']; nst_device_wallet_out([ 'ok' => true, 'action' => $request['action'], 'assignment' => $result['status'], 'revision' => $result['revision'], 'seed' => $seed, 'addr' => addr_from_seed($seed), ]); } /* -------- Empire setup console (filled gate · pack law) -------- */ function nst_empire_setup_path(): string { return nst_data_file('empire_setup.json'); } /** @return array{filled:bool,version:int,filled_ts?:int,ladder_posted?:int,note?:string} */ function nst_empire_setup_state(): array { $p = nst_empire_setup_path(); if (!is_file($p)) { return ['filled' => false, 'version' => 1]; } $j = json_decode((string)@file_get_contents($p), true); if (!is_array($j)) { return ['filled' => false, 'version' => 1]; } return [ 'filled' => !empty($j['filled']), 'version' => (int)($j['version'] ?? 1), 'filled_ts' => isset($j['filled_ts']) ? (int)$j['filled_ts'] : null, 'ladder_posted' => isset($j['ladder_posted']) ? (int)$j['ladder_posted'] : null, 'note' => isset($j['note']) ? (string)$j['note'] : null, ]; } function nst_empire_setup_is_filled(): bool { return !empty(nst_empire_setup_state()['filled']); } function nst_empire_setup_write(array $state): bool { global $DATA; if (!is_dir($DATA) && !@mkdir($DATA, 0755, true)) { return false; } $state['version'] = 1; $raw = json_encode($state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if ($raw === false) { return false; } return @file_put_contents(nst_empire_setup_path(), $raw . "\n", LOCK_EX) !== false; } /** Count open book kinds for setup status (no secrets). */ function nst_empire_setup_book_census(): array { ensure_genesis(); $orders = open_orders(read_chain()); $ladder = 0; $half = 0; $boot = 0; $fillableSell = 0; foreach ($orders as $o) { $k = (string)($o['kind'] ?? ''); $side = (string)($o['side'] ?? ''); if ($k === 'treasury_ladder') { $ladder++; } if ($k === 'treasury_half_bid') { $half++; } if ($k === 'bootstrap_spread') { $boot++; } if ($side === 'sell' && $k !== 'bootstrap_spread') { $fillableSell++; } } return [ 'treasury_ladder' => $ladder, 'treasury_half_bid' => $half, 'bootstrap_spread' => $boot, 'fillable_sell' => $fillableSell, ]; } /** * Visitor face gate: until operator marks empire setup filled, redirect to setup console. * Exempt: setup console, controlpanel, src/download, any ?api= (agents / selfhash / money matrix). */ function nst_empire_setup_maybe_redirect(): void { if (nst_empire_setup_is_filled()) { return; } if (isset($_GET['empire_setup']) || isset($_GET['setup'])) { return; } if (isset($_GET['controlpanel'])) { return; } if (isset($_GET['src']) || isset($_GET['download'])) { return; } $api = (string)($_GET['api'] ?? $_POST['api'] ?? ''); if ($api !== '') { return; } $uri = (string)($_SERVER['REQUEST_URI'] ?? ''); if (preg_match('#/(controlpanel|setup)/?(\?|$)#', $uri)) { return; } header('Cache-Control: no-store'); header('Location: /?empire_setup=1', true, 302); exit; } function nst_render_empire_setup_console(): void { ensure_genesis(); $st = nst_empire_setup_state(); $book = nst_empire_setup_book_census(); $esc = static function (string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }; $filled = !empty($st['filled']); header('Content-Type: text/html; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); echo ''; echo ''; echo 'Empire setup · nosignup
'; echo '

DNA · EMPIRE SETUP · trade host

'; echo '

Nosignup empire setup

'; if ($filled) { echo '
Filled. Public product faces are open. Visitors are no longer forced here.
'; } else { echo '
Not filled. Visitors hitting product faces on this host are redirected here until KING marks setup complete. Sister crops should redirect to ' . $esc(NST_EMPIRE_SETUP_PUBLIC_URL) . '.
'; } echo '
Free NSU faucet stays 410 retired. Buy path needs real treasury_ladder asks (not bootstrap discovery quotes).
'; echo '

Status

'; echo '
Setup filled' . ($filled ? 'yes' : 'no') . '
'; echo '
Treasury ladder asks' . (int)$book['treasury_ladder'] . '
'; echo '
Half-bids' . (int)$book['treasury_half_bid'] . '
'; echo '
Fillable sells (non-bootstrap)' . (int)$book['fillable_sell'] . '
'; echo '
Bootstrap quotes' . (int)$book['bootstrap_spread'] . '
'; echo '
Version' . $esc(NST_VERSION) . '
'; echo '

Operator steps

    '; echo '
  1. Paste KING faucet seed below (treasury mint authority — not visitor wallet).
  2. '; echo '
  3. Seed treasury ask ladder — posts real fillable SELLs + half-bids (idempotent).
  4. '; echo '
  5. Confirm fillable sells > 0 above.
  6. '; echo '
  7. Mark empire filled — lifts visitor redirect on this host; sisters use public status / their gate.
  8. '; echo '
'; echo '

KING seed

'; echo ''; echo '

Seed stays in this browser only for the button actions. Never paste into agent chat / STACK / skill files.

'; echo ''; echo ''; echo '
'; echo ''; echo ''; echo '
'; echo ''; echo '

Full control panel · '; echo 'Try product face (redirects here until filled)

'; echo '
'; exit; } /* -------- Public source (?src=1 / ?download=1) - no auth, before APIs -------- */ if (isset($_GET['src']) || isset($_GET['download']) || (isset($_GET['api']) && (string)$_GET['api'] === 'src')) { $raw = (string)file_get_contents(__FILE__); header('Content-Type: text/plain; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); header('X-NS-Sha256: ' . hash('sha256', $raw)); /* P1-TRADE-SRC-INLINE RR231: ?src=1 inline like sisters; ?download=1 saves file */ if (isset($_GET['download'])) { header('Content-Disposition: attachment; filename="index.php"'); } header('Content-Length: ' . (string)strlen($raw)); echo $raw; exit; } /* Device binding must remain before generic API genesis, chain replay, and economy hooks. */ if (isset($_GET['api']) && (string)$_GET['api'] === 'device_wallet') { nst_device_wallet_handle(); } /* -------- API -------- */ $api = $_GET['api'] ?? $_POST['api'] ?? ''; if ($api === 'audit') { /* PUBLIC AND PAGED, so a stranger can pull the WHOLE ledger and check it * themselves rather than trusting anything this server says about itself. * That is the only defence that survives a King editing his own code. * CORS for the same reason ad_pick has it: public, read-only, no secrets. * Structural corruption still fails; a linkage break remains readable so * the public verifier can name the exact broken row. No genesis boot. */ nst_audit_api_out(read_chain(false)); } if ($api !== '') { ensure_genesis(); $chain = read_chain(); $bal = balances($chain); $taddr = treasury_addr(); // E2: honor freeze always; full recheck on force=1 or throttle expiry $forceConserve = ((string)($_GET['force'] ?? $_POST['force'] ?? '') === '1'); $nstConserveSnap = nst_conservation_boot_check($chain, $forceConserve); /* RR293/RR313: public book for tools/sisters — read-only, not mint. */ if ($api === 'book') { $book = nst_public_book($chain); $bootstrapSpreadActive = false; foreach (array_merge($book['bids'] ?? [], $book['asks'] ?? []) as $o) { if (($o['kind'] ?? '') === 'bootstrap_spread') { $bootstrapSpreadActive = true; break; } } api_out([ 'ok' => true, 'schema' => 'nosignup.trade.book.v1', 'version' => NST_VERSION, 'price_policy' => 'market_converge', 'bootstrap_spread_active' => $bootstrapSpreadActive, 'bootstrap_note' => 'bootstrap_spread quotes are discovery only — not fillable', 'market_p_fmt' => $book['mid'] ?? null, 'book' => $book, 'law' => 'Read-only public book · no free NSU · fill via POST ?api=fill with seed', ]); } if ($api === 'state') { $book = nst_public_book($chain); $mid = $book['mid'] ?? null; $ep = epoch_info(); $meta = load_meta(); $pop = (int)($meta['population'] ?? 0); $target = (string)($meta['treasury_target_micros'] ?? ''); if ($target === '' || !dec_ok($target)) { $pi = nst_world_population(); $pop = (int)$pi['pop']; $target = nst_optimum_treasury_micros($pop); } $bootstrapSpreadActive = false; foreach (array_merge($book['bids'] ?? [], $book['asks'] ?? []) as $o) { if (($o['kind'] ?? '') === 'bootstrap_spread') { $bootstrapSpreadActive = true; break; } } $bbPx = nst_buyback_price_micros($chain); /* Reuse the chain rows this handler already loaded: one replay, not seven. */ $nsSupMinted = nst_minted_to_date($chain); $nsSupRemain = nst_supply_remaining($chain); $nsSupEmit = nst_emission_today($chain); api_out([ 'ok' => true, 'version' => NST_VERSION, 'price_policy' => 'market_converge', 'bootstrap_spread_active' => $bootstrapSpreadActive, 'buyback_note' => 'donate buyback arms at market P/2 (not fixed sticker)', 'market_p_fmt' => $mid, 'buyback_px_fmt' => $bbPx !== null ? fmt_amt($bbPx) : null, 'panel_unlock' => 'site_wallet_seed', // browser_hmac_v1: client HMAC with seed still on wire (fallback). // ecdsa_p256_v1: register_pubkey once, then signed ops omit seed. 'client_sign' => 'browser_hmac_or_ecdsa_p256', 'sig_mode' => 'ecdsa_p256_or_browser_hmac_or_server_mac', 'seed_on_wire' => 'unless_ecdsa_registered', 'seed_off_wire' => 'ecdsa_after_register_pubkey', 'asymmetric_plan' => 'ECDSA_P256_register_pubkey_then_seed_off_wire', 'ecdsa_spend' => 'register_pubkey_then_signed_ops_without_seed', 'ecdsa_ops' => ['transfer', 'order', 'cancel', 'fill', 'reputation'], 'wallet_inbox' => 'v0+autowire experimental · address-keyed sealed mail · mail not mint', 'inbox_e2e' => 'client_encrypt_server_ciphertext_only · experimental · not proven e2e', 'inbox_server_notice' => 'server-composed public book facts · encrypted for delivery · NOT private E2E', 'inbox_key_sep' => 'ECDSA_sign≠ECDH_enc (encpub_ vs pubkey_)', // Soft surface: hosts without OpenSSL cannot verify ECDSA (HMAC fallback still works). 'ecdsa_server' => nst_ecdsa_openssl_ready() ? 'openssl_available' : 'openssl_unavailable', 'ecdsa_verify_ready' => nst_ecdsa_openssl_ready(), /* ONE chain replay feeds all three figures. Calling the helpers * inline read the whole chain seven times per request, which is * O(7N) on a ledger that only grows. */ 'max_supply_fmt' => fmt_amt((string)($meta['soft_max_supply_micros'] ?? NST_MAX_SUPPLY)), /* Supply is public and replayable. Anyone can fetch the chain and * recompute every one of these; nothing here is a stored figure we * ask you to trust. That is the point - a currency whose issuance * cannot be independently checked is a promise, not money. */ 'minted_to_date' => $nsSupMinted, 'minted_to_date_fmt' => fmt_amt($nsSupMinted), 'supply_remaining' => $nsSupRemain, 'supply_remaining_fmt' => fmt_amt($nsSupRemain), 'emission_today' => $nsSupEmit, 'emission_today_fmt' => fmt_amt($nsSupEmit), 'emission_rate_ppm' => NST_EMISSION_DAILY_RATE_PPM, 'emission_split' => [ 'rule' => 'equal share per crop, King tax off the top of each share', 'crop_count' => NST_EMISSION_CROP_COUNT, 'crops_registered' => count(nst_crop_addrs()), 'king_tax_ppm' => NST_KING_TAX_PPM, /* Public, because a silent redirect of 95% of issuance is exactly * the thing an operator should not be able to do quietly. */ 'registry_anchored' => (bool)nst_crop_anchor(), 'registry_file_matches_chain' => !nst_crop_registry_mismatch(), ], 'king_tax_ppm' => NST_KING_TAX_PPM, /* Same money? A label - the proof is replaying a peer chain. */ 'policy' => nsu_policy_hash(), 'policy_badge' => nsu_policy_badge(), 'mint_types' => nst_mint_types(), 'supply_law' => 'Empires start at ZERO. Coins enter ONLY via daily emission ' . 'E = (max_supply - minted_to_date) * rate_ppm/1e6, under a hard 100M ceiling ' . 'enforced at a single mint choke point. No participant can trigger a mint. ' . 'The yearly treasury re-anchor is RETIRED (it refunded whatever the King spent).', 'treasury_addr' => $taddr, 'treasury_bal_fmt' => fmt_amt($bal[$taddr] ?? '0'), 'treasury_target_fmt' => fmt_amt($target), 'population' => $pop, 'population_source' => (string)($meta['population_source'] ?? ''), 'optimum_nsu_per_human' => fmt_amt(NST_OPTIMUM_MICROS_PER_HUMAN), // legacy metadata only 'chain_len' => count($chain), 'book' => $book, 'pairs' => $book['pairs'] ?? [], 'recent_fills' => nst_public_recent_fills($chain), // Legacy audit tail remains for old tools; pair/chart discovery must use recent_fills. 'recent' => array_slice($chain, -50), 'epoch' => $ep, 'admin_pass_set' => false, 'early_daily_fmt' => fmt_amt(NST_MOD_DAILY_MICROS), 'economy' => (function () use ($chain) { $e = load_economy(); $midM = book_mid_micros($chain); $bbM = nst_buyback_price_micros($chain); return [ 'profit_fmt' => fmt_amt((string)($e['profit_micros'] ?? '0')), 'ad_bid_fmt' => fmt_amt((string)($e['ad_bid_micros'] ?? '0')), 'mid_fmt' => $midM !== null ? fmt_amt($midM) : null, 'buyback_px_fmt' => $bbM !== null ? fmt_amt($bbM) : null, 'donate_k' => (string)($e['donate_k'] ?? NST_DONATE_K), 'value_note' => (string)($e['value_note'] ?? ''), 'value_authority' => 'this_server_owner', 'mirrors_role' => 'free_tributaries', ]; })(), 'conservation' => nst_conservation_public_view($nstConserveSnap), 'writes_frozen' => nst_conservation_is_frozen(), 'self_hash_hint' => 'sha256 of this index.php on disk - compute offline to verify release', ]); } if ($api === 'balance') { $a = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_REQUEST['addr'] ?? ''))); if (strlen($a) !== 64) api_out(['ok' => false, 'err' => 'Need a 64-char hex wallet address'], 400); $b = $bal[$a] ?? '0'; $res = reserved_nsu($chain, $a); $av = available_nsu($bal, $chain, $a); $reps = rep_scores($chain); // history for addr $hist = []; foreach (array_reverse($chain) as $r) { if (($r['type'] ?? '') === 'transfer' && (($r['from'] ?? '') === $a || ($r['to'] ?? '') === $a)) { $hist[] = $r; if (count($hist) >= 40) break; } if (($r['type'] ?? '') === 'order' && ($r['addr'] ?? '') === $a) { $hist[] = $r; if (count($hist) >= 40) break; } if (($r['type'] ?? '') === 'fill' && (($r['maker'] ?? '') === $a || ($r['taker'] ?? '') === $a)) { $hist[] = $r; if (count($hist) >= 40) break; } if (($r['type'] ?? '') === 'reputation' && (($r['rater'] ?? '') === $a || ($r['rated'] ?? '') === $a)) { $hist[] = $r; if (count($hist) >= 40) break; } if (($r['type'] ?? '') === 'modifier_grant' && ($r['addr'] ?? '') === $a) { $hist[] = $r; if (count($hist) >= 40) break; } } $mods = load_mods(); $mod = $mods[$a] ?? null; api_out([ 'ok' => true, 'addr' => $a, 'balance' => $b, 'balance_fmt' => fmt_amt($b), 'reserved' => $res, 'reserved_fmt' => fmt_amt($res), 'available' => $av, 'available_fmt' => fmt_amt($av), 'rep_score' => $reps[$a] ?? 0, 'history' => $hist, 'has_modifier' => !empty($mod['permanent']), 'modifier' => $mod ? [ 'permanent' => !empty($mod['permanent']), 'daily_fmt' => fmt_amt((string)($mod['daily_micros'] ?? NST_MOD_DAILY_MICROS)), 'granted_ts' => (int)($mod['granted_ts'] ?? 0), 'granted_epoch' => (int)($mod['granted_epoch'] ?? 0), 'last_accrual_ts' => (int)($mod['last_accrual_ts'] ?? 0), ] : null, 'epoch' => epoch_info(), ]); } if ($api === 'addr' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); [$seed, $addr] = require_seed(); nst_device_wallet_guard_actor([ 'addr' => $addr, 'seed' => $seed, 'ecdsa' => false, 'spki_hex' => '', 'ecdsa_sig' => '', ]); $grant = grant_early_modifier($addr); $acc = lazy_accrue($addr); api_out([ 'ok' => true, 'addr' => $addr, 'modifier_grant' => $grant, 'accrual' => $acc, 'epoch' => epoch_info(), ]); } if ($api === 'accrue' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); [$seed, $addr] = require_seed(); nst_device_wallet_guard_actor([ 'addr' => $addr, 'seed' => $seed, 'ecdsa' => false, 'spki_hex' => '', 'ecdsa_sig' => '', ]); $grant = grant_early_modifier($addr); $acc = lazy_accrue($addr); $bal = balances(read_chain()); api_out([ 'ok' => true, 'addr' => $addr, 'modifier_grant' => $grant, 'accrual' => $acc, 'balance_fmt' => fmt_amt($bal[$addr] ?? '0'), 'available_fmt' => fmt_amt(available_nsu($bal, read_chain(), $addr)), 'has_modifier' => !empty(load_mods()[$addr]['permanent']), ]); } /** P1-TRADE-INBOX-METHOD RR211 + P1-TRADE-ADDR-ACCRUE-METHOD RR212: * non-POST free write routes must not fall through as unknown api (404). */ $nst_post_only_free = [ 'register_pubkey', 'register_enc_pubkey', 'inbox_seal', 'inbox_list', 'inbox_fetch', 'reputation', 'addr', 'accrue', ]; /** P1-TRADE-MONEY-KING-MATRIX GO: money write routes same 405 honesty (not 404 unknown). */ $nst_post_only_money = ['transfer', 'order', 'cancel', 'fill']; $nst_post_only = array_merge($nst_post_only_free, $nst_post_only_money); if (in_array($api, $nst_post_only, true) && strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { api_out(['ok' => false, 'err' => 'POST only'], 405); } // Register ECDSA P-256 SPKI for legacy addr (seed proves ownership once; later spends can omit seed). if ($api === 'register_pubkey' && $_SERVER['REQUEST_METHOD'] === 'POST') { if (!nst_ecdsa_openssl_ready()) { api_out([ 'ok' => false, 'err' => 'ecdsa verify unavailable (openssl extension required)', 'ecdsa_server' => 'openssl_unavailable', ], 503); } [$seed, $addr] = require_seed(); nst_device_wallet_guard_actor([ 'addr' => $addr, 'seed' => $seed, 'ecdsa' => false, 'spki_hex' => '', 'ecdsa_sig' => '', ]); $spkiHex = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['pubkey'] ?? '')) ?? ''); $spki = @hex2bin($spkiHex); if ($spki === false || strlen($spki) < 50) { api_out(['ok' => false, 'err' => 'Bad public key — check the key and try again'], 400); } $ts = (int)($_POST['ts'] ?? 0); if ($ts < 1) { $ts = time(); } elseif (abs($ts - time()) > 600) { api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); } $proof = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['ecdsa_sig'] ?? '')) ?? ''); $body = implode('|', ['register_pubkey', '1', $addr, $spkiHex, (string)$ts]); if ($proof === '' || !nst_ecdsa_verify_p256($spki, $body, $proof)) { api_out(['ok' => false, 'err' => 'Bad signature for that public key — refresh and try again'], 400); } $existingSpki = nst_load_registered_spki($addr); if ($existingSpki !== null && !hash_equals($existingSpki, $spki)) { api_out([ 'ok' => false, 'err' => 'Another optional signer is already registered; the 12 words still spend from every device', 'seed_fallback' => true, 'pubkey_registered' => false, ], 409); } if ($existingSpki === null && !nst_save_registered_spki($addr, $spki)) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } api_out([ 'ok' => true, 'addr' => $addr, 'pubkey_registered' => true, 'sig_mode' => 'ecdsa_p256_v1', 'note' => 'Pubkey stored. Spend may omit seed when ECDSA path works (experimental · not proven e2e); HMAC/seed still real fallback', ]); } // --- Wallet Inbox v0: enc-pubkey registry + sealed mail (ciphertext only) --- if ($api === 'register_enc_pubkey' && $_SERVER['REQUEST_METHOD'] === 'POST') { [$seed, $addr] = require_seed(); nst_device_wallet_guard_actor([ 'addr' => $addr, 'seed' => $seed, 'ecdsa' => false, 'spki_hex' => '', 'ecdsa_sig' => '', ]); $spkiHex = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['enc_pubkey'] ?? $_POST['pubkey'] ?? '')) ?? ''); $spki = @hex2bin($spkiHex); if ($spki === false || strlen($spki) < 50) { api_out(['ok' => false, 'err' => 'bad enc pubkey'], 400); } if (!nst_save_registered_enc_spki($addr, $spki)) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } api_out([ 'ok' => true, 'addr' => $addr, 'enc_pubkey_registered' => true, 'key_usage' => 'ECDH_P256_encrypt_only', 'distinct_from' => 'ECDSA_P256_sign (register_pubkey)', 'note' => 'Encryption SPKI stored. Seal/decrypt experimental · not proven e2e until isolated proof', 'mail_not_mint' => true, ]); } // Public fetch of encryption SPKI (needed by sender to seal; not a secret). if ($api === 'inbox_enc_pubkey') { $addr = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_GET['addr'] ?? $_POST['addr'] ?? ''))) ?? ''; if (strlen($addr) !== 64) api_out(['ok' => false, 'err' => 'Need a 64-char hex wallet address'], 400); $spki = nst_load_registered_enc_spki($addr); if ($spki === null) { api_out(['ok' => false, 'err' => 'no enc pubkey registered', 'addr' => $addr], 404); } api_out([ 'ok' => true, 'addr' => $addr, 'enc_pubkey' => bin2hex($spki), 'alg' => NST_INBOX_ALG, 'note' => 'Public encryption key only · experimental wallet inbox v0', ]); } /** * KING seal: accept PRE-ENCRYPTED blob only (ct + eph_pub + iv + sig). * Server NEVER accepts plaintext for storage. require_faucet_auth. * Mail ≠ mint: no chain row, no balance change. */ if ($api === 'inbox_seal' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_faucet_auth(); // Refuse accidental plaintext legs (honesty floor). foreach (['plaintext', 'note', 'body', 'message', 'msg'] as $ban) { if (isset($_POST[$ban]) && trim((string)$_POST[$ban]) !== '') { api_out([ 'ok' => false, 'err' => 'plaintext not accepted — encrypt client-side; server stores ciphertext only', 'honesty' => 'refuse_server_plaintext_seal', ], 400); } } $to = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['to'] ?? ''))) ?? ''; if (strlen($to) !== 64) api_out(['ok' => false, 'err' => 'Bad destination address — need a 64-char hex wallet address'], 400); if (nst_load_registered_enc_spki($to) === null) { api_out(['ok' => false, 'err' => 'recipient has no enc pubkey — they must unlock wallet once'], 400); } $from = strtolower(addr_from_seed(treasury_secret())); $id = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['id'] ?? ''))) ?? ''; if ($id === '') $id = bin2hex(random_bytes(8)); if (strlen($id) < 8 || strlen($id) > 64) api_out(['ok' => false, 'err' => 'bad id'], 400); $ts = (int)($_POST['ts'] ?? time()); if (abs($ts - time()) > 600) api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); $ttl = (int)($_POST['ttl'] ?? NST_INBOX_TTL_SECS); if ($ttl < 60) $ttl = 60; if ($ttl > NST_INBOX_TTL_SECS * 4) $ttl = NST_INBOX_TTL_SECS * 4; $exp = $ts + $ttl; $eph = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['eph_pub'] ?? '')) ?? ''); $iv = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['iv'] ?? '')) ?? ''); $ct = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['ct'] ?? '')) ?? ''); if (strlen($eph) < 100 || strlen($iv) !== 24 || strlen($ct) < 32) { api_out(['ok' => false, 'err' => 'bad ciphertext fields (eph_pub/iv/ct)'], 400); } if (strlen($ct) > NST_INBOX_MAX_CT_HEX) api_out(['ok' => false, 'err' => 'ct too large'], 400); $blob = [ 'v' => 1, 'id' => $id, 'to' => $to, 'from' => $from, 'ts' => $ts, 'ttl' => $ttl, 'exp' => $exp, 'eph_pub' => $eph, 'iv' => $iv, 'ct' => $ct, 'alg' => NST_INBOX_ALG, 'kind' => 'king_note', 'mail_not_mint' => true, 'honesty' => 'experimental · sealed wallet mail · server ciphertext only · not proven e2e', ]; $signBody = nst_inbox_sign_body($blob); $sig = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['sig'] ?? $_POST['ecdsa_sig'] ?? '')) ?? ''); $sigMode = strtolower(trim((string)($_POST['sig_mode'] ?? ''))); $spkiHex = strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['pubkey'] ?? '')) ?? ''); if ($sigMode === 'ecdsa_p256_v1' || ($sig !== '' && $spkiHex !== '' && nst_ecdsa_openssl_ready())) { $spki = @hex2bin($spkiHex); if ($spki === false || strlen($spki) < 50) api_out(['ok' => false, 'err' => 'bad sender pubkey'], 400); $reg = nst_load_registered_spki($from); if ($reg === null || !hash_equals($reg, $spki)) { // Allow seal-time register: if KING posts matching seed-proven from, require proof over register body first is separate. // Here: if no registry, accept SPKI only when ECDSA verifies AND from matches treasury (KING seed already checked). if ($reg !== null && !hash_equals($reg, $spki)) { api_out(['ok' => false, 'err' => 'sender pubkey not registered for treasury addr'], 403); } if ($reg === null) { nst_save_registered_spki($from, $spki); } } if ($sig === '' || !nst_ecdsa_verify_p256($spki, $signBody, $sig)) { api_out(['ok' => false, 'err' => 'bad ecdsa seal signature'], 400); } $blob['sig'] = $sig; $blob['sig_mode'] = 'ecdsa_p256_v1'; $blob['sender_spki'] = bin2hex($spki); } else { // Experimental HMAC fallback (seed already on wire for faucet auth) — labeled. $seed = (string)($_POST['seed'] ?? ''); $expect = mac_sign($seed, $signBody); if ($sig !== '' && !hash_equals($expect, $sig)) { api_out(['ok' => false, 'err' => 'bad hmac seal signature'], 400); } if ($sig === '') $sig = $expect; $blob['sig'] = $sig; $blob['sig_mode'] = 'server_mac'; $blob['honesty'] = 'experimental · sealed mail · HMAC seal (seed on wire for KING auth) · ciphertext-only store · not proven e2e'; } $path = nst_inbox_blob_path($to, $id); if (@file_put_contents($path, j($blob), LOCK_EX) === false) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } api_out([ 'ok' => true, 'id' => $id, 'to' => $to, 'from' => $from, 'exp' => $exp, 'stored' => basename($path), 'mail_not_mint' => true, 'server_plaintext' => false, 'sig_mode' => $blob['sig_mode'], 'honesty' => $blob['honesty'], 'note' => 'Opaque ciphertext stored only · recipient decrypts client-side with seed-derived ECDH key', ]); } // List own sealed blobs (seed or ECDSA actor). if ($api === 'inbox_list' && $_SERVER['REQUEST_METHOD'] === 'POST') { $actor = nst_actor('addr'); $addr = $actor['addr']; // Light auth: list is not secret metadata-only; still require ownership of addr. if (!empty($actor['ecdsa'])) { $body = implode('|', ['inbox_list', '1', $addr, (string)(int)($_POST['ts'] ?? time())]); nst_auth_sig($actor, $body); } $items = nst_inbox_list_for_addr($addr); api_out([ 'ok' => true, 'addr' => $addr, 'count' => count($items), 'items' => $items, 'mail_not_mint' => true, 'honesty' => 'experimental · list returns metadata + ids; ct only via inbox_fetch · not proven e2e', ]); } // Fetch one sealed blob (opaque fields for client decrypt). if ($api === 'inbox_fetch' && $_SERVER['REQUEST_METHOD'] === 'POST') { $actor = nst_actor('addr'); $addr = $actor['addr']; $id = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['id'] ?? ''))) ?? ''; if ($id === '') api_out(['ok' => false, 'err' => 'need id'], 400); if (!empty($actor['ecdsa'])) { $body = implode('|', ['inbox_fetch', '1', $addr, $id, (string)(int)($_POST['ts'] ?? time())]); nst_auth_sig($actor, $body); } $path = nst_inbox_blob_path($addr, $id); if (!is_file($path)) { $path = nst_inbox_blob_path_legacy($addr, $id); } if (!is_file($path)) api_out(['ok' => false, 'err' => 'That was not found — refresh and try again'], 404); $j = json_decode((string)@file_get_contents($path), true); if (!is_array($j) || (string)($j['to'] ?? '') !== $addr) { api_out(['ok' => false, 'err' => 'That was not found — refresh and try again'], 404); } $exp = (int)($j['exp'] ?? 0); if ($exp > 0 && $exp < time()) { @unlink($path); api_out(['ok' => false, 'err' => 'That item expired — refresh and try again'], 410); } api_out([ 'ok' => true, 'blob' => [ 'v' => (int)($j['v'] ?? 1), 'id' => (string)($j['id'] ?? ''), 'to' => (string)($j['to'] ?? ''), 'from' => (string)($j['from'] ?? ''), 'ts' => (int)($j['ts'] ?? 0), 'exp' => $exp, 'eph_pub' => (string)($j['eph_pub'] ?? ''), 'iv' => (string)($j['iv'] ?? ''), 'ct' => (string)($j['ct'] ?? ''), 'sig' => (string)($j['sig'] ?? ''), 'sig_mode' => (string)($j['sig_mode'] ?? ''), 'sender_spki' => (string)($j['sender_spki'] ?? ''), 'alg' => (string)($j['alg'] ?? NST_INBOX_ALG), 'kind' => (string)($j['kind'] ?? 'king_note'), 'honesty' => (string)($j['honesty'] ?? 'experimental'), ], 'mail_not_mint' => true, 'server_plaintext' => false, 'note' => 'Decrypt client-side with seed-derived ECDH key · experimental · not proven e2e', ]); } if ($api === 'transfer' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); // Auth first (seed/ECDSA) so missing unlock is not masked as "bad to". $actor = nst_actor('from'); $from = $actor['addr']; $to = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['to'] ?? ''))); $micros = parse_amt(trim((string)($_POST['amount'] ?? ''))); $memo = substr(trim((string)($_POST['memo'] ?? '')), 0, 120); $nonce = substr(trim((string)($_POST['nonce'] ?? bin2hex(random_bytes(8)))), 0, 64); if (strlen($to) !== 64) api_out(['ok' => false, 'err' => 'Bad destination address — need a 64-char hex wallet address'], 400); /* RR316 P1-TRADE-AMOUNT-ERR-PLAIN: zero/neg/parse-fail amount is plain English (not "bad amount"). */ if ($micros === null || dec_cmp($micros, '1') < 0) { api_out(['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)'], 400); } $ts = (int)($_POST['ts'] ?? 0); if ($ts < 1) { $ts = time(); } elseif (abs($ts - time()) > 600) { api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); } // Dual-path: ECDSA seed-off-wire OR seed + optional browser HMAC. if ($from === $to) api_out(['ok' => false, 'err' => 'Cannot send to yourself'], 400); $body = implode('|', ['transfer', '1', $from, $to, $micros, $nonce, (string)$ts, $memo]); $auth = nst_auth_sig($actor, $body); $sigMode = $auth['sig_mode']; $rowSig = $auth['sig']; $spkiHex = $auth['pubkey']; $risk = nst_brain_memo_risk($memo); $row = [ 'type' => 'transfer', 'ver' => 1, 'from' => $from, 'to' => $to, 'amount' => $micros, 'amount_fmt' => fmt_amt($micros), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => $rowSig, 'sig_mode' => $sigMode, ]; if ($sigMode === 'ecdsa_p256_v1' && $spkiHex !== '') { $row['pubkey'] = $spkiHex; } if (!empty($risk['flag'])) $row['memo_risk'] = $risk; // Exclusive RMW: concurrent transfers cannot both pass available_nsu and overdraw. $xferOut = nst_with_chain_lock(function (array $chain, callable $append) use ($from, $to, $micros, $nonce, $row) { foreach ($chain as $r) { if (($r['type'] ?? '') === 'transfer' && ($r['from'] ?? '') === $from && ($r['nonce'] ?? '') === $nonce) { return ['ok' => false, 'http' => 400, 'err' => 'That transfer was already sent (nonce replay) — use a new nonce']; } } $bal = balances($chain); $av = available_nsu($bal, $chain, $from); if (dec_cmp($av, $micros) < 0) { return [ 'ok' => false, 'http' => 400, 'err' => 'Not enough free NSU — cancel open sells or buy more on the book', 'have_fmt' => fmt_amt($bal[$from] ?? '0'), 'available_fmt' => fmt_amt($av), ]; } if (!$append($row)) { return ['ok' => false, 'http' => 500, 'err' => 'Could not save to the server — try again in a moment']; } return [ 'ok' => true, 'tx' => $row, 'balance_fmt' => fmt_amt(dec_sub($bal[$from] ?? '0', $micros)), ]; }); if ($xferOut === null) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } if (empty($xferOut['ok'])) { $http = (int)($xferOut['http'] ?? 400); unset($xferOut['http']); api_out($xferOut, $http); } // P3c/d: rent memo → try settle; on refundable failure, auto-refund payer from treasury. $txRow = is_array($xferOut['tx'] ?? null) ? $xferOut['tx'] : $row; $rentSettle = nst_rent_try_settle_transfer($txRow); $rentRefund = null; if (empty($rentSettle['settled']) && nst_rent_refundable_reason((string)($rentSettle['reason'] ?? ''))) { $rentRefund = nst_rent_auto_refund($txRow, $rentSettle); } // Rent notices AFTER settle/refund commit; fail-open (public lease facts). try { if (!empty($rentSettle['settled'])) { nst_inbox_notify_rent_settle($rentSettle); } if (is_array($rentRefund) && !empty($rentRefund['refunded'])) { nst_inbox_notify_rent_refund($rentRefund); } } catch (Throwable $e) { // never abort a committed transfer/rent op } api_out([ 'ok' => true, 'tx' => $xferOut['tx'], 'sig_mode' => $sigMode, 'seed_on_wire' => $auth['seed_on_wire'], 'balance_fmt' => $xferOut['balance_fmt'], 'brains' => ['memo_risk' => $risk], 'rent' => $rentSettle, 'rent_refund' => $rentRefund, ]); } /** Free claim retired — always 410 (any method). No mint, no transfer. * P1-FAUCET-GET RR206: GET must not 404 unknown api while face says retired. */ if ($api === 'faucet') { api_out([ 'ok' => false, 'err' => 'Free NSU claim retired — buy NSU on the order book (Trade book tab)', 'law' => 'OPERATOR_GO 2026-07-16: KING path SELL/BUY only; free giveaway OUT. ' . 'Site use stays free (wallet, view, post). NSU is the tradeable coin, not a free drip.', 'free_claim' => 'retired', 'next' => 'place a buy order on the book · ?api=order side=buy', ], 410); } if ($api === 'order' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); $actor = nst_actor('addr'); $addr = $actor['addr']; $side = strtolower(trim((string)($_POST['side'] ?? ''))); $pair = strtoupper(trim((string)($_POST['pair'] ?? 'NSU/NOTE'))); $amount = parse_amt(trim((string)($_POST['amount'] ?? ''))); $price = parse_amt(trim((string)($_POST['price'] ?? ''))); $expiry = (int)($_POST['expiry'] ?? (time() + 86400)); if (!in_array($side, ['buy', 'sell'], true)) api_out(['ok' => false, 'err' => 'Side must be buy or sell'], 400); /* RR328 P1-TRADE-ORDER-AMOUNT-HARD: zero/neg amount or price must hard-reject (brain is soft-only). */ if ($amount === null || dec_cmp($amount, '1') < 0) { api_out(['ok' => false, 'err' => 'Order amount must be a positive number (at least 0.000001 NSU)'], 400); } if ($price === null || dec_cmp($price, '1') < 0) { api_out(['ok' => false, 'err' => 'Order price must be a positive number'], 400); } $sanity = nst_brain_order_sanity($side, $amount, $price); $pairBrain = nst_brain_pair_sane($pair); if ($expiry < time() + 60) $expiry = time() + 3600; if ($expiry > time() + 30 * 86400) $expiry = time() + 30 * 86400; // open-count + sell available re-checked under chain RMW lock below $ts = (int)($_POST['ts'] ?? 0); if ($ts < 1) { $ts = time(); } elseif (abs($ts - time()) > 600) { api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); } $idIn = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['order_id'] ?? ''))) ?? ''; if (strlen($idIn) === 64) { $id = $idIn; } else { $id = hash('sha256', $addr . '|' . $ts . '|' . $side . '|' . $amount . '|' . $price . '|' . bin2hex(random_bytes(4))); } $body = implode('|', ['order', '1', $id, $addr, $side, $pair, $amount, $price, (string)$expiry, (string)$ts]); $auth = nst_auth_sig($actor, $body); $sigMode = $auth['sig_mode']; $row = [ 'type' => 'order', 'ver' => 1, 'id' => $id, 'addr' => $addr, 'side' => $side, 'pair' => $pair, 'amount' => $amount, 'amount_fmt' => fmt_amt($amount), 'price' => $price, 'price_fmt' => fmt_amt($price), 'expiry' => $expiry, 'ts' => $ts, 'sig' => $auth['sig'], 'sig_mode' => $sigMode, ]; if ($sigMode === 'ecdsa_p256_v1' && $auth['pubkey'] !== '') { $row['pubkey'] = $auth['pubkey']; } if (!empty($sanity['flag'])) $row['order_sanity'] = $sanity; if (!empty($pairBrain['flag'])) $row['pair_sane'] = $pairBrain; // Exclusive RMW: concurrent sells cannot over-reserve; open-count + id uniqueness under lock. // OPERATOR_GO §3c: treasury/KING SELL on book immediately arms BID @ half (same pair). // Pre-read treasury material OUTSIDE chain lock (treasury_secret → ensure_genesis can append_row). $tsecForHalf = ''; $taddrForHalf = ''; try { $tsecForHalf = treasury_secret(); $taddrForHalf = strtolower(addr_from_seed($tsecForHalf)); } catch (Throwable $e) { $tsecForHalf = ''; $taddrForHalf = ''; } $isTreasurySell = ($side === 'sell' && $taddrForHalf !== '' && strtolower($addr) === $taddrForHalf && (($row['kind'] ?? '') !== 'bootstrap_spread')); $orderOut = nst_with_chain_lock(function (array $chain, callable $append) use ( $addr, $side, $pair, $amount, $id, $row, $isTreasurySell, $expiry, $ts, $tsecForHalf, $taddrForHalf ) { foreach ($chain as $r) { if (($r['type'] ?? '') === 'order' && ($r['id'] ?? '') === $id) { return ['ok' => false, 'http' => 400, 'err' => 'order_id already used']; } } $n = 0; foreach (open_orders($chain) as $o) { if (($o['addr'] ?? '') === $addr) $n++; } // Treasury SELL needs a free slot for the paired half-BID (two posts). $slotCap = $isTreasurySell ? 19 : 20; if ($n >= $slotCap) { return ['ok' => false, 'http' => 400, 'err' => 'too many open orders']; } if ($side === 'sell' && str_starts_with($pair, 'NSU')) { $bal = balances($chain); $av = available_nsu($bal, $chain, $addr); if (dec_cmp($av, $amount) < 0) { return [ 'ok' => false, 'http' => 400, 'err' => 'Not enough free NSU — cancel open sells or buy more on the book', 'available_fmt' => fmt_amt($av), ]; } } if (!$append($row)) { return ['ok' => false, 'http' => 500, 'err' => 'Could not save to the server — try again in a moment']; } $halfBid = null; if ($isTreasurySell) { $hb = nst_treasury_half_bid_from_sell( (string)$id, (string)$pair, (string)$amount, (string)($row['price'] ?? '0'), (int)$expiry, (int)$ts, $chain, $append, (string)$tsecForHalf, (string)$taddrForHalf ); if (empty($hb['ok'])) { return [ 'ok' => false, 'http' => 500, 'err' => 'treasury half-bid failed after sell', 'detail' => $hb['err'] ?? 'unknown', 'order' => $row, ]; } $halfBid = $hb['order'] ?? null; } return ['ok' => true, 'order' => $row, 'half_bid' => $halfBid]; }); if ($orderOut === null) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } if (empty($orderOut['ok'])) { $http = (int)($orderOut['http'] ?? 400); unset($orderOut['http']); api_out($orderOut, $http); } $outPayload = [ 'ok' => true, 'order' => $orderOut['order'], 'sig_mode' => $sigMode, 'seed_on_wire' => $auth['seed_on_wire'], 'brains' => ['order_sanity' => $sanity, 'pair_sane' => $pairBrain], ]; if (!empty($orderOut['half_bid'])) { $outPayload['half_bid'] = $orderOut['half_bid']; $outPayload['half_bid_note'] = 'OPERATOR_GO §3c: treasury SELL armed paired BID @ half price'; } // Half-BID armed notice: AFTER chain write, fail-open (public book floor). try { if (!empty($orderOut['half_bid']) && is_array($orderOut['half_bid'])) { $sellRow = is_array($orderOut['order'] ?? null) ? $orderOut['order'] : null; nst_inbox_notify_half_bid($sellRow, $orderOut['half_bid']); } } catch (Throwable $e) { // never abort a committed order/half-BID } api_out($outPayload); } if ($api === 'cancel' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); $actor = nst_actor('addr'); $addr = $actor['addr']; $oid = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['order_id'] ?? ''))); if (strlen($oid) !== 64) api_out(['ok' => false, 'err' => 'Bad order id — use a full 64-char open order id from the book'], 400); $chain = read_chain(); $found = null; foreach ($chain as $r) { if (($r['type'] ?? '') === 'order' && ($r['id'] ?? '') === $oid) $found = $r; } if (!$found) api_out(['ok' => false, 'err' => 'That order is not open (already filled, cancelled, or expired)'], 404); if (($found['addr'] ?? '') !== $addr) api_out(['ok' => false, 'err' => 'not owner'], 403); // Honesty: only open book rows may cancel (filled/cancelled/expired → not open). // Not a fund-lock invent: fill already rechecks open_orders under nst_with_chain_lock. $stillOpen = false; foreach (open_orders($chain) as $oLive) { if (($oLive['id'] ?? '') === $oid) { $stillOpen = true; break; } } if (!$stillOpen) { api_out(['ok' => false, 'err' => 'That order is not open (already filled, cancelled, or expired)'], 404); } $ts = (int)($_POST['ts'] ?? 0); if ($ts < 1) { $ts = time(); } elseif (abs($ts - time()) > 600) { api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); } $body = implode('|', ['cancel', '1', $oid, $addr, (string)$ts]); $auth = nst_auth_sig($actor, $body); $sigMode = $auth['sig_mode']; $row = [ 'type' => 'cancel', 'ver' => 1, 'order_id' => $oid, 'addr' => $addr, 'ts' => $ts, 'sig' => $auth['sig'], 'sig_mode' => $sigMode, ]; if ($sigMode === 'ecdsa_p256_v1' && $auth['pubkey'] !== '') { $row['pubkey'] = $auth['pubkey']; } if (!append_row($row)) api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); api_out(['ok' => true, 'cancel' => $row, 'sig_mode' => $sigMode, 'seed_on_wire' => $auth['seed_on_wire']]); } // Take a live order: move NSU leg when pair starts with NSU // sell order: maker sells NSU -> transfer maker->taker // buy order: maker buys NSU -> transfer taker->maker (taker must hold NSU) // Chain exclusive RMW under LOCK_EX so concurrent fills cannot over-debit maker. if ($api === 'fill' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); /* New clients send the signing wallet explicitly. The taker fallback * preserves older buy-fill clients; on a sell release the signer is * the maker while the taker field is the named buyer. */ $actor = nst_actor(array_key_exists('actor', $_POST) ? 'actor' : 'taker'); $taker = $actor['addr']; $oid = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['order_id'] ?? ''))); $qtyH = trim((string)($_POST['amount'] ?? '')); $settle = substr(trim((string)($_POST['settle_ref'] ?? '')), 0, 160); if (strlen($oid) !== 64) api_out(['ok' => false, 'err' => 'Bad order id — use a full 64-char open order id from the book'], 400); $clientFillId = strtolower(trim((string)($_POST['fill_id'] ?? ''))); if ($clientFillId !== '' && preg_match('/\A[0-9a-f]{64}\z/D', $clientFillId) !== 1) { api_out(['ok' => false, 'err' => 'Bad fill id — refresh the book and try again'], 400); } $fillId = $clientFillId !== '' ? $clientFillId : hash( 'sha256', 'legacy-fill-v1|' . $taker . '|' . $oid . '|' . $qtyH . '|' . $settle . '|' . (string)($_POST['ts'] ?? '') . '|' . (string)($_POST['nonce'] ?? '') ); // Pre-read for auth body qty (signatures bind amount). Remaining re-checked under lock. $chainPeek = read_chain(); foreach ($chainPeek as $prior) { if (($prior['type'] ?? '') !== 'fill' || ($prior['fill_id'] ?? '') !== $fillId) continue; if (($prior['order_id'] ?? '') !== $oid) { api_out(['ok' => false, 'err' => 'fill id already used for another order'], 409); } $priorSide = (string)($prior['side'] ?? ''); $priorMaker = (string)($prior['maker'] ?? ''); $priorTaker = (string)($prior['taker'] ?? ''); $authorizedAddr = $priorSide === 'sell' ? $priorMaker : $priorTaker; if (!hash_equals($authorizedAddr, $taker)) { api_out(['ok' => false, 'err' => 'fill id belongs to another wallet'], 403); } $requestedQty = $qtyH === '' ? (string)($prior['amount'] ?? '') : parse_amt($qtyH); $priorQty = dec_norm((string)($prior['amount'] ?? '0')); $priorTs = (int)($prior['ts'] ?? 0); if (!is_string($requestedQty) || !hash_equals($priorQty, dec_norm($requestedQty)) || !hash_equals((string)($prior['settle_ref'] ?? ''), $settle) ) { api_out(['ok' => false, 'err' => 'fill id already committed with different details'], 409); } if ($priorSide === 'sell') { $retryBuyer = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['buyer'] ?? ''))) ?? ''; if (strlen($retryBuyer) !== 64 || !hash_equals($priorTaker, $retryBuyer)) { api_out(['ok' => false, 'err' => 'fill id already committed for another buyer'], 409); } } $priorVersion = (int)($prior['ver'] ?? 1) >= 2 ? 2 : 1; $retryBody = $priorVersion === 2 ? implode('|', ['fill', '2', $fillId, $oid, $priorMaker, $priorTaker, $priorQty, $settle, (string)$priorTs]) : implode('|', ['fill', '1', $oid, $priorMaker, $priorTaker, $priorQty, $settle, (string)$priorTs]); /* A committed response may have been lost, but a fill id is not a * bearer token. Re-verify the original signed intent before * disclosing idempotent success. */ $retryAuth = nst_auth_sig($actor, $retryBody); $priorTx = null; foreach ($chainPeek as $candidateTx) { if (($candidateTx['type'] ?? '') === 'transfer' && ($candidateTx['order_id'] ?? '') === $oid && ($candidateTx['fill_id'] ?? '') === $fillId ) { $priorTx = $candidateTx; break; } } api_out([ 'ok' => true, 'idempotent' => true, 'fill' => $prior, 'transfer' => $priorTx, 'sig_mode' => (string)($prior['sig_mode'] ?? 'server_mac'), 'seed_on_wire' => $retryAuth['seed_on_wire'], ]); } $ordersPeek = open_orders($chainPeek); $foundPeek = null; foreach ($ordersPeek as $o) if (($o['id'] ?? '') === $oid) { $foundPeek = $o; break; } if (!$foundPeek) api_out(['ok' => false, 'err' => 'order not open'], 404); // Bootstrap BUY/SELL are temp mid discovery only — never a free NSU faucet path. if (($foundPeek['kind'] ?? '') === 'bootstrap_spread') { api_out([ 'ok' => false, 'err' => 'Bootstrap discovery quote — not fillable. Post your own order or take a real maker.', 'kind' => 'bootstrap_spread', ], 400); } $maker = (string)($foundPeek['addr'] ?? ''); $sidePeek = (string)($foundPeek['side'] ?? ''); /* NOBODY MOVES SOMEONE ELSE'S NSU. * * A sell fill debits the MAKER and credits the taker. This used to run on * the maker's ORIGINAL order signature ("auth: open_sell_order") with the * TAKER as the only authenticated party - and nothing anywhere verified * that the taker had paid the off-platform leg. `settle_ref` is a free * text field. So any open sell order was free money for whoever called * fill first: reproduced with the literal settle_ref "i-promise-i-paid", * moving 150 NSU from a maker who consented to nothing. * * Listing an intent to sell is not consent to be debited by a stranger. * Every other spend path in this file authenticates the wallet the money * LEAVES; fill was the sole exception, and the exception was the bug. * * So for a sell, the party who is authenticated must be the maker - the * NSU owner releases their own coins once they have been paid, and names * the buyer. For a buy the taker spends their OWN NSU, so the existing * taker signature is already the right one and nothing changes. * * This is NOT escrow, which the charter refuses ("NEVER BECOME: * custody/escrow"): the site never holds a coin and never adjudicates. * It only declines to move a wallet without its owner's live say-so. * The counterparty risk that remains - a maker who takes payment and * does not release - is inherent to any non-custodial P2P trade and is * the honest version of what the book already claimed to be. */ if ($sidePeek === 'sell') { if ($taker !== $maker) { api_out([ 'ok' => false, 'err' => 'Only the seller can release NSU. Pay the maker off-platform, then ' . 'the maker submits this fill naming you as buyer.', 'why' => 'A sell fill debits the maker. Authorising it with the taker\'s key ' . 'would let anyone drain any open sell order.', 'maker_submits' => true, ], 403); } $buyer = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['buyer'] ?? ''))) ?? ''; if (strlen($buyer) !== 64) { api_out(['ok' => false, 'err' => 'Need the buyer\'s 64-char address (buyer=...)'], 400); } if ($buyer === $maker) { api_out(['ok' => false, 'err' => 'Cannot sell to yourself'], 400); } $taker = $buyer; // the party receiving the NSU } elseif ($maker === $taker) { api_out(['ok' => false, 'err' => 'Cannot take your own order'], 400); } $remPeek = $foundPeek['remaining'] ?? '0'; $qty = $qtyH === '' ? $remPeek : parse_amt($qtyH); if ($qty === null || dec_cmp($qty, '1') < 0) api_out(['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)'], 400); if (dec_cmp($qty, $remPeek) > 0) api_out(['ok' => false, 'err' => 'That order no longer has enough left — pick a smaller amount or another order', 'remaining_fmt' => fmt_amt($remPeek)], 400); $pair = strtoupper((string)($foundPeek['pair'] ?? '')); $side = (string)($foundPeek['side'] ?? ''); $ts = (int)($_POST['ts'] ?? 0); if ($ts < 1) { $ts = time(); } elseif (abs($ts - time()) > 600) { api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); } $nonce = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['nonce'] ?? ''))) ?? ''; if (strlen($nonce) < 8) { $nonce = bin2hex(random_bytes(8)); } $memo = 'fill:' . substr($oid, 0, 12); $tx = null; $txAuth = null; if (str_starts_with($pair, 'NSU')) { if ($side === 'sell') { $from = $maker; $to = $taker; } elseif ($side === 'buy') { $from = $taker; $to = $maker; } else { api_out(['ok' => false, 'err' => 'Pick buy or sell'], 400); } $tbody = implode('|', ['transfer', '1', $from, $to, $qty, $nonce, (string)$ts, $memo]); // For sell: maker already authorized via open sell order sig. // For buy: taker spends — ECDSA or browser HMAC on transfer body (signed before lock). if ($side === 'sell') { $tx = [ 'type' => 'transfer', 'ver' => 1, 'from' => $maker, 'to' => $taker, 'amount' => $qty, 'amount_fmt' => fmt_amt($qty), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => $foundPeek['sig'] ?? '', 'auth' => 'open_sell_order', 'order_id' => $oid, ]; } else { // ECDSA: require dedicated tx_ecdsa_sig (do not reuse fill ecdsa_sig). $txEcdsaOverride = !empty($actor['ecdsa']) ? strtolower(preg_replace('/[^a-f0-9]/', '', (string)($_POST['tx_ecdsa_sig'] ?? '')) ?? '') : null; $txAuth = nst_auth_sig($actor, $tbody, 'tx_client_sig', $txEcdsaOverride); $tx = [ 'type' => 'transfer', 'ver' => 1, 'from' => $taker, 'to' => $maker, 'amount' => $qty, 'amount_fmt' => fmt_amt($qty), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => $txAuth['sig'], 'sig_mode' => $txAuth['sig_mode'], 'auth' => $txAuth['sig_mode'] === 'ecdsa_p256_v1' ? 'taker_ecdsa' : 'taker_seed', 'order_id' => $oid, ]; if ($txAuth['sig_mode'] === 'ecdsa_p256_v1' && $txAuth['pubkey'] !== '') { $tx['pubkey'] = $txAuth['pubkey']; } } } $fillVersion = $clientFillId !== '' ? 2 : 1; $fbody = $fillVersion === 2 ? implode('|', ['fill', '2', $fillId, $oid, $maker, $taker, $qty, $settle, (string)$ts]) : implode('|', ['fill', '1', $oid, $maker, $taker, $qty, $settle, (string)$ts]); $fillAuth = nst_auth_sig($actor, $fbody); $sigMode = $fillAuth['sig_mode']; $fillOut = nst_with_chain_lock(function (array $chain, callable $append, callable $appendBatch) use ( $oid, $taker, $maker, $qty, $settle, $ts, $pair, $side, $tx, $fillAuth, $sigMode, $nonce, $fillId, $fillVersion ) { foreach ($chain as $prior) { if (($prior['type'] ?? '') !== 'fill' || ($prior['fill_id'] ?? '') !== $fillId) continue; if (($prior['order_id'] ?? '') !== $oid) { return ['ok' => false, 'http' => 409, 'err' => 'fill id already used for another order']; } $authorizedAddr = (($prior['side'] ?? '') === 'sell') ? (string)($prior['maker'] ?? '') : (string)($prior['taker'] ?? ''); $actorAddr = (($side ?? '') === 'sell') ? $maker : $taker; if (!hash_equals($authorizedAddr, $actorAddr)) { return ['ok' => false, 'http' => 403, 'err' => 'fill id belongs to another wallet']; } $priorTx = null; foreach ($chain as $candidateTx) { if (($candidateTx['type'] ?? '') === 'transfer' && ($candidateTx['order_id'] ?? '') === $oid && ($candidateTx['fill_id'] ?? '') === $fillId ) { $priorTx = $candidateTx; break; } } return [ 'ok' => true, 'idempotent' => true, 'fill' => $prior, 'transfer' => $priorTx, 'sig_mode' => (string)($prior['sig_mode'] ?? $sigMode), 'seed_on_wire' => $fillAuth['seed_on_wire'], ]; } $orders = open_orders($chain); $found = null; foreach ($orders as $o) if (($o['id'] ?? '') === $oid) { $found = $o; break; } if (!$found) { return ['ok' => false, 'http' => 404, 'err' => 'order not open']; } if (($found['kind'] ?? '') === 'bootstrap_spread') { return [ 'ok' => false, 'http' => 400, 'err' => 'Bootstrap discovery quote — not fillable. Post your own order or take a real maker.', 'kind' => 'bootstrap_spread', ]; } if ((string)($found['addr'] ?? '') !== $maker) { return ['ok' => false, 'http' => 400, 'err' => 'order changed — refresh book']; } if ((string)($found['side'] ?? '') !== $side || strtoupper((string)($found['pair'] ?? '')) !== $pair) { return ['ok' => false, 'http' => 400, 'err' => 'order changed — refresh book']; } $rem = $found['remaining'] ?? '0'; if (dec_cmp($qty, $rem) > 0) { return ['ok' => false, 'http' => 400, 'err' => 'That order no longer has enough left — pick a smaller amount or another order', 'remaining_fmt' => fmt_amt($rem)]; } // Reject identical fill packet replay (sell leg has no client-bound nonce on fill body). foreach ($chain as $r) { if (($r['type'] ?? '') !== 'fill') continue; if (($r['order_id'] ?? '') !== $oid) continue; if (($r['taker'] ?? '') !== $taker) continue; if ((int)($r['ts'] ?? 0) !== $ts) continue; if (dec_norm((string)($r['amount'] ?? '0')) !== dec_norm($qty)) continue; return ['ok' => false, 'http' => 400, 'err' => 'fill replay — refresh book']; } $txRow = $tx; if (str_starts_with($pair, 'NSU')) { $bal = balances($chain); if ($side === 'sell') { $have = $bal[$maker] ?? '0'; if (dec_cmp($have, $qty) < 0) { return ['ok' => false, 'http' => 400, 'err' => 'maker lacks NSU']; } } elseif ($side === 'buy') { $av = available_nsu($bal, $chain, $taker); if (dec_cmp($av, $qty) < 0) { return ['ok' => false, 'http' => 400, 'err' => 'Not enough free NSU — cancel open sells or buy more on the book', 'available_fmt' => fmt_amt($av)]; } } // Same nonce uniqueness as transfer API (buy-leg replay must not re-spend). if ($txRow) { $txFrom = (string)($txRow['from'] ?? ''); $txNonce = (string)($txRow['nonce'] ?? $nonce); if ($txFrom !== '' && $txNonce !== '') { foreach ($chain as $r) { if (($r['type'] ?? '') === 'transfer' && ($r['from'] ?? '') === $txFrom && ($r['nonce'] ?? '') === $txNonce) { return ['ok' => false, 'http' => 400, 'err' => 'That transfer was already sent (nonce replay) — use a new nonce']; } } } // Refresh sell-order sig from live row under lock (maker re-auth not required). if ($side === 'sell') { $txRow['sig'] = $found['sig'] ?? ($txRow['sig'] ?? ''); } $txRow['fill_id'] = $fillId; /* The transfer is staged below with its fill in one batch. */ } } $fill = [ 'type' => 'fill', 'ver' => $fillVersion, 'fill_id' => $fillId, 'order_id' => $oid, 'maker' => $maker, 'taker' => $taker, 'side' => $side, 'pair' => $pair, 'amount' => $qty, 'amount_fmt' => fmt_amt($qty), 'price' => $found['price'] ?? '0', 'price_fmt' => $found['price_fmt'] ?? '', 'settle_ref' => $settle, 'ts' => $ts, 'taker_sig' => $fillAuth['sig'], 'sig_mode' => $sigMode, ]; if ($sigMode === 'ecdsa_p256_v1' && $fillAuth['pubkey'] !== '') { $fill['pubkey'] = $fillAuth['pubkey']; } /* One durable intent, one append payload, one recovery decision. * A process loss can no longer leave the transfer committed while * the order remains open because its fill row never landed. */ $batch = $txRow ? [$txRow, $fill] : [$fill]; if (!$appendBatch($batch)) { return ['ok' => false, 'http' => 500, 'err' => 'Could not complete that trade fill — try again in a moment']; } return [ 'ok' => true, 'fill' => $fill, 'transfer' => $txRow, 'sig_mode' => $sigMode, 'seed_on_wire' => $fillAuth['seed_on_wire'], ]; }); if ($fillOut === null) { api_out(['ok' => false, 'err' => 'Could not complete that trade fill — try again in a moment'], 500); } if (empty($fillOut['ok'])) { $http = (int)($fillOut['http'] ?? 400); unset($fillOut['http']); api_out($fillOut, $http); } // Money-adjacent server_notice: AFTER fill commits, OUTSIDE chain lock, fail-open. try { if (is_array($fillOut['fill'] ?? null)) { nst_inbox_notify_fill($fillOut['fill']); } } catch (Throwable $e) { // never abort a committed fill } api_out($fillOut); } if ($api === 'reputation' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); $actor = nst_actor('rater'); $rater = $actor['addr']; $rated = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['rated'] ?? ''))); $score = (int)($_POST['score'] ?? 0); $ctx = substr(trim((string)($_POST['context'] ?? '')), 0, 160); if (strlen($rated) !== 64) api_out(['ok' => false, 'err' => 'bad rated'], 400); if ($rated === $rater) api_out(['ok' => false, 'err' => 'Cannot send to yourself'], 400); if ($score < -10 || $score > 10) api_out(['ok' => false, 'err' => 'score -10..10'], 400); $ts = (int)($_POST['ts'] ?? 0); if ($ts < 1) { $ts = time(); } elseif (abs($ts - time()) > 600) { api_out(['ok' => false, 'err' => 'Clock skew — refresh the page and try again'], 400); } $body = implode('|', ['reputation', '1', $rater, $rated, (string)$score, $ctx, (string)$ts]); $auth = nst_auth_sig($actor, $body); $sigMode = $auth['sig_mode']; $row = [ 'type' => 'reputation', 'ver' => 1, 'rater' => $rater, 'rated' => $rated, 'score' => $score, 'context' => $ctx, 'ts' => $ts, 'sig' => $auth['sig'], 'sig_mode' => $sigMode, ]; if ($sigMode === 'ecdsa_p256_v1' && $auth['pubkey'] !== '') { $row['pubkey'] = $auth['pubkey']; } if (!append_row($row)) api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); $scores = rep_scores(read_chain()); api_out([ 'ok' => true, 'edge' => $row, 'rated_total' => $scores[$rated] ?? 0, 'sig_mode' => $sigMode, 'seed_on_wire' => $auth['seed_on_wire'], ]); } if ($api === 'audit') { nst_audit_api_out(read_chain(false)); } if ($api === 'verify') { /* Audit a FOREIGN chain with this empire's own verifier. * * The rows are POSTed in, never fetched by this server. Fetching a * caller-supplied URL would make every empire an SSRF proxy into its own * host's private network - and the browser can fetch the peer directly * anyway, since ?api=audit is public and CORS-enabled. The party who * wants the answer does the fetching; we only do the arithmetic. * * No auth: verification reads nothing local and reveals nothing. */ header('Access-Control-Allow-Origin: *'); $raw = (string)($_POST['rows'] ?? ''); if ($raw === '') { api_out(['ok' => false, 'err' => 'POST rows= as JSON array, or JSONL, from a peer ?api=audit'], 400); } $rows = json_decode($raw, true); if (!is_array($rows)) { // accept raw JSONL too $rows = []; foreach (preg_split('/\r?\n/', $raw) as $line) { $line = trim($line); if ($line === '') continue; $j = json_decode($line, true); if (is_array($j)) $rows[] = $j; } } if (!$rows) api_out(['ok' => false, 'err' => 'no rows parsed'], 400); if (count($rows) > 200000) api_out(['ok' => false, 'err' => 'too many rows'], 413); api_out(['ok' => true, 'verify' => nst_verify_chain($rows)]); } if ($api === 'conservation') { $chain = read_chain(); $force = ((string)($_GET['force'] ?? $_POST['force'] ?? '1') !== '0'); $rep = $force ? nst_conservation_check_and_maybe_freeze($chain) : null; if ($rep === null) { $snap = nst_conservation_boot_check($chain, false); api_out(['ok' => true, 'conservation' => nst_conservation_public_view($snap)]); } api_out([ 'ok' => true, 'conservation' => [ 'ok' => !empty($rep['ok']), 'frozen' => !empty($rep['frozen']) || nst_conservation_is_frozen(), 'sum_fmt' => fmt_amt((string)$rep['observed_sum']), 'expected_fmt' => fmt_amt((string)$rep['expected_issued']), 'checked_ts' => (int)$rep['checked_ts'], 'int_ok' => !empty($rep['int_ok']), 'detail' => $rep['detail'] ?? null, 'freeze_path_basename' => 'conservation.freeze.json', ], ]); } if ($api === 'policy') { /* "Are we on the same money?" - a LABEL, not a proof. A king who edits * this file can make it return anything, exactly as with selfhash. The * proof is the replay: pull a peer's ?api=audit and run ?api=verify. * CORS because it is public, read-only and carries no secrets. */ header('Access-Control-Allow-Origin: *'); $missing = nsu_policy_missing(); $h = nsu_policy_hash(); api_out([ 'ok' => true, 'policy' => $h, 'badge' => nsu_policy_badge($h), 'constants' => nsu_policy_constants(), 'rules' => nsu_policy_functions(), 'rules_count' => count(nsu_policy_functions()), 'missing_rules' => $missing, 'intact' => !$missing, 'note' => 'POLICY is a label: it says what the replay OUGHT to conclude. ' . 'Verify a peer by pulling ?api=audit and POSTing the rows to ?api=verify. ' . 'Nothing a server says about itself is evidence.', ]); } if ($api === 'selfhash') { /* RR324 P1-TRADE-SELFHASH-ECDSA-CAPS: surface seed-off-wire readiness on public selfhash (utility/safety discoverability). */ api_out([ 'ok' => true, 'version' => NST_VERSION, 'sha256' => @hash_file('sha256', __FILE__) ?: null, 'file' => basename(__FILE__), 'ecdsa_verify_ready' => nst_ecdsa_openssl_ready(), 'auth_modes' => ['seed', 'browser_hmac_v1', 'ecdsa_p256_v1'], 'seed_off_wire' => 'register_pubkey then signed transfer/order/cancel/fill/reputation omit seed', 'no_free_mint' => true, ]); } /* ---- Operator console (key-combo UI + password; secrets in data/*.txt) ---- */ if ($api === 'admin_status') { $eStat = load_economy(); $opStat = strtolower(trim((string)($eStat['operator_addr'] ?? ''))); $rentedStat = !empty($eStat['rented']) && $opStat !== '' && $opStat !== strtolower($taddr); api_out([ 'ok' => true, 'admin_pass_set' => false, 'site' => 'trade', 'version' => NST_VERSION, 'epoch' => epoch_info(), 'mods_count' => count(load_mods()), 'treasury_addr' => $taddr, 'treasury_bal_fmt' => fmt_amt($bal[$taddr] ?? '0'), 'chain_len' => count($chain), 'rented' => $rentedStat, 'operator_addr' => $opStat !== '' ? $opStat : $taddr, 'lord_rent_grant' => 'retired', 'lord_rent_credit_legacy' => null, 'rent_quote_api' => 'rent_quote', 'rent_quote_get_api' => 'rent_quote_get', 'rent_settle_api' => 'rent_settle', 'rent_quotes_durable' => true, 'rent_settle' => 'transfer_memo_first_paid_wins', 'rent_nsu_per_day' => (int)NST_RENT_NSU_PER_DAY, 'lord_rent_formula' => [ 'status' => 'retired_grant_path', 'law' => 'rent is payment TO treasury (NSU_VIABILITY_V1); ?api=rent_quote live; pay-bind settle next', 'nsu_per_day' => (int)NST_RENT_NSU_PER_DAY, 'faucet_start_nsu_narrative' => NST_KING_FAUCET_START_NSU, ], 'last_epoch_profit_fmt' => fmt_amt((string)($eStat['last_epoch_profit_micros'] ?? '0')), 'panel_door' => 'E3: trade panel site.seed or KING treasury (owner) or LORD operator_addr; faucet=KING only; leftover admin password dead', ]); } if ($api === 'admin_login' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); $e = load_economy(); $mid = book_mid_micros($chain); api_out([ 'ok' => true, 'msg' => 'ok', 'epoch' => epoch_info(), 'mods_count' => count(load_mods()), 'treasury_addr' => $taddr, 'treasury_bal_fmt' => fmt_amt($bal[$taddr] ?? '0'), 'chain_len' => count($chain), 'vault_hint' => vault_dir(), 'site' => 'trade', 'version' => NST_VERSION, 'economy' => [ 'profit_fmt' => fmt_amt((string)($e['profit_micros'] ?? '0')), 'ad_bid_fmt' => fmt_amt((string)($e['ad_bid_micros'] ?? '0')), 'donate_k' => (string)($e['donate_k'] ?? NST_DONATE_K), 'value_note' => (string)($e['value_note'] ?? ''), 'value_authority' => 'this_server_owner', 'mirrors_role' => 'free_tributaries', 'mid_fmt' => $mid !== null ? fmt_amt($mid) : null, 'bids_n' => is_array($e['bids'] ?? null) ? count($e['bids']) : 0, ], 'donate_addrs' => [ 'btc' => NST_DONATE_BTC, 'xmr' => NST_DONATE_XMR, 'ltc' => NST_DONATE_LTC, ], ]); } /** Renter sets local NSU value policy (k + public note). Not a global peg. Mirrors do not inherit. */ if ($api === 'admin_set_value' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); $e = load_economy(); $k = trim((string)($_POST['donate_k'] ?? '')); if ($k !== '') { if (!preg_match('/^\d+(\.\d{1,8})?$/', $k) || (float)$k <= 0) { api_out(['ok' => false, 'err' => 'donate_k must be positive number'], 400); } $e['donate_k'] = $k; } if (array_key_exists('value_note', $_POST)) { $note = trim((string)$_POST['value_note']); if (strlen($note) > 240) $note = substr($note, 0, 240); $e['value_note'] = $note; } save_economy($e); api_out([ 'ok' => true, 'msg' => 'local value policy saved - this server owner only; mirrors stay free tributaries', 'donate_k' => (string)($e['donate_k'] ?? NST_DONATE_K), 'value_note' => (string)($e['value_note'] ?? ''), 'value_authority' => 'this_server_owner', 'mirrors_role' => 'free_tributaries', ]); } /* LORD SOVEREIGNTY on trade — rotate the PANEL seed only. * * Same rule as the other nine crops (see any of them for the full reasoning): * the ten panel seeds are minted by genesis and printed in the king's * GENESIS-INFO.txt, so until this existed a lord's door was issued by the * landlord who kept a copy, and admin_change_pass rotated only the legacy * password - not the seed, which is the real door. * * TRADE IS THE DANGEROUS ONE, because this host holds two seeds: * * data/site.seed the LORD panel door + trade's crop wallet * data/treasury.secret the KING mint - the only thing that can issue NSU * * This endpoint touches ONLY the first. It is gated on require_admin (the * panel seed), so a lord can free their own crop, and it must never become a * path by which a lord reaches the mint. If these two files are ever * conflated, whoever rents trade owns the money supply of the whole empire. */ if ($api === 'admin_rekey' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); global $SITE_SEED_FILE, $TFILE; if ((string)($_POST['confirm'] ?? '') !== 'REKEY') { api_out([ 'ok' => false, 'err' => 'Re-keying replaces this crop\'s panel seed permanently. There is no ' . 'recovery desk. POST confirm=REKEY to proceed.', 'confirm_required' => 'REKEY', ], 400); } $oldSeed = norm_seed((string)@file_get_contents($SITE_SEED_FILE)); $oldAddr = $oldSeed !== '' ? addr_from_seed($oldSeed) : ''; $new = nst_generate_site_seed(); $newAddr = addr_from_seed($new); if ($new === '' || $newAddr === '' || $newAddr === $oldAddr) { api_out(['ok' => false, 'err' => 'seed generation failed'], 500); } /* Paranoia, cheap: never let a panel rotation collide with the mint key. */ $tsec = is_file($TFILE) ? norm_seed((string)@file_get_contents($TFILE)) : ''; if ($tsec !== '' && norm_seed($new) === $tsec) { api_out(['ok' => false, 'err' => 'generated seed collided with treasury - refused'], 500); } nst_vault_panel_seed_note($new); if (@file_put_contents($SITE_SEED_FILE, $new . "\n", LOCK_EX) === false) { api_out(['ok' => false, 'err' => 'could not write site.seed - crop unchanged, old seed still valid'], 500); } @chmod($SITE_SEED_FILE, 0600); $check = norm_seed((string)@file_get_contents($SITE_SEED_FILE)); if ($check !== norm_seed($new)) { api_out(['ok' => false, 'err' => 'readback mismatch - rotation not confirmed'], 500); } api_out([ 'ok' => true, 'seed' => $new, 'seed_shown_once' => true, 'old_addr' => $oldAddr, 'new_addr' => $newAddr, 'vault' => 'SITE_WALLET_SEED.txt', 'treasury_untouched' => true, 'next_steps' => [ 'SAVE THIS SEED OFFLINE NOW. It is shown once and there is no recovery desk.', 'The king\'s copy of the previous panel seed no longer opens this crop.', 'The KING mint (data/treasury.secret) is untouched - this rotates the panel only.', 'Your NSU is still at the OLD address - the old seed opens that wallet. ' . 'Transfer it to the new address deliberately; nothing is swept for you.', 'Emission for this crop still pays the OLD address until a successor is ' . 'anchored in the on-chain crop registry.', ], 'msg' => 'Panel seed rotated. This crop is now yours alone; the mint is not.', ]); } if ($api === 'admin_reveal' && $_SERVER['REQUEST_METHOD'] === 'POST') { // KING / legacy only — LORD must never receive treasury.secret (CONTRACT §3). require_faucet_auth(); global $TFILE, $MODS_FILE, $META, $CHAIN, $ADMIN_HASH_FILE; api_out([ 'ok' => true, 'admin_pass' => null, 'admin_pass_note' => 'Leftover admin password files are unlinked. Product door is site seed.', 'treasury_secret' => treasury_secret(), 'treasury_addr' => $taddr, 'vault_dir' => vault_dir(), 'paths' => [ 'admin_hash' => basename($ADMIN_HASH_FILE), 'treasury_secret' => basename($TFILE), 'modifiers' => basename($MODS_FILE), 'meta' => basename($META), 'chain' => basename($CHAIN), ], 'note' => 'KING faucet material. Renters never receive this. OS root stays SSH. Host compromise = game over for this crop.', ]); } // Renter utter control: read/replace THIS index.php (app god-mode, not OS root) if ($api === 'admin_get_source' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); $raw = (string)file_get_contents(__FILE__); api_out([ 'ok' => true, 'bytes' => strlen($raw), 'sha256' => hash('sha256', $raw), 'source' => $raw, 'warning' => 'Saving a broken file can kill this site until SSH restore.', ]); } /* KING ONLY: download a deployable virgin pack of this empire. * * Gate is require_faucet_auth() - the TREASURY seed, not the panel seed. A * lord holds his crop; only the King carries the empire, so only the King * pulls a copy of it. * * The pack lives in data/, which nginx denies and which sits under the same * protection as chain.jsonl and site.seed. It is NOT in the public web root: * the ten sites stay individually auditable via ?src=1 / ?download=1, but * the assembled, deployable bundle is an operator tool, not a public asset. * * Deploy.bat writes data/pack.zip on every deploy, already stripped of * GENESIS-INFO, seeds, vault/ and data/ by a filter plus a hard gate. The * King can therefore stand up a sister empire from a running one without * needing the machine he first deployed from. */ if ($api === 'king_virgin_pack' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_faucet_auth(); $packFile = __DIR__ . DIRECTORY_SEPARATOR . NST_DATA . DIRECTORY_SEPARATOR . 'pack.zip'; if (!is_file($packFile)) { api_out([ 'ok' => false, 'err' => 'No pack stored on this server yet - run Deploy.bat once and it publishes one.', ], 404); } $raw = (string)@file_get_contents($packFile); if ($raw === '') { api_out(['ok' => false, 'err' => 'Pack file unreadable on the server — try again or re-upload'], 500); } /* Paranoia: never hand out a bundle carrying seed material, even though * the client filtered it. Cheap to check, catastrophic to skip. */ foreach (['GENESIS-INFO', 'treasury.secret', 'SITE_WALLET_SEED', 'KING_FAUCET_SEED'] as $needle) { if (strpos($raw, $needle) !== false) { api_out([ 'ok' => false, 'err' => 'pack refused: it contains seed-shaped material. Re-publish it with Deploy.bat.', ], 500); } } header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="NSU-virgin.zip"'); header('Content-Length: ' . strlen($raw)); header('X-NS-Sha256: ' . hash('sha256', $raw)); header('Cache-Control: no-store'); echo $raw; exit; } if ($api === 'admin_put_source' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); require_admin(); $src = (string)($_POST['source'] ?? ''); if (strlen($src) < 100) api_out(['ok' => false, 'err' => 'source too short'], 400); if (strpos($src, ' false, 'err' => 'must look like PHP'], 400); $bak = __FILE__ . '.bak.' . time(); @copy(__FILE__, $bak); if (file_put_contents(__FILE__, $src, LOCK_EX) === false) { api_out(['ok' => false, 'err' => 'write failed - check perms'], 500); } api_out([ 'ok' => true, 'msg' => 'index.php replaced', 'backup' => basename($bak), 'sha256' => hash('sha256', $src), 'bytes' => strlen($src), ]); } if ($api === 'admin_pay' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); // Free-form treasury pay = KING faucet authority (not LORD panel seed alone). require_faucet_auth(); $to = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['to'] ?? ''))); $micros = parse_amt(trim((string)($_POST['amount'] ?? ''))); $memo = substr(trim((string)($_POST['memo'] ?? 'admin:pay')), 0, 120); if (strlen($to) !== 64) api_out(['ok' => false, 'err' => 'Bad destination address — need a 64-char hex wallet address'], 400); if ($micros === null || dec_cmp($micros, '1') < 0) api_out(['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)'], 400); $sec = treasury_secret(); $from = addr_from_seed($sec); $ts = time(); $nonce = bin2hex(random_bytes(8)); $body = implode('|', ['transfer', '1', $from, $to, $micros, $nonce, (string)$ts, $memo]); $row = [ 'type' => 'transfer', 'ver' => 1, 'from' => $from, 'to' => $to, 'amount' => $micros, 'amount_fmt' => fmt_amt($micros), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => mac_sign($sec, $body), 'kind' => 'admin_pay', ]; $payOut = nst_with_chain_lock(function (array $chain, callable $append) use ($from, $to, $micros, $nonce, $row) { foreach ($chain as $r) { if (($r['type'] ?? '') === 'transfer' && ($r['from'] ?? '') === $from && ($r['nonce'] ?? '') === $nonce) { return ['ok' => false, 'http' => 400, 'err' => 'That transfer was already sent (nonce replay) — use a new nonce']; } } $bal = balances($chain); $have = $bal[$from] ?? '0'; if (dec_cmp($have, $micros) < 0) { return ['ok' => false, 'http' => 400, 'err' => 'Treasury does not have enough free NSU for that — try a smaller amount', 'have_fmt' => fmt_amt($have)]; } if (!$append($row)) { return ['ok' => false, 'http' => 500, 'err' => 'Could not save to the server — try again in a moment']; } return ['ok' => true, 'tx' => $row, 'treasury_bal_fmt' => fmt_amt(dec_sub($have, $micros))]; }); if ($payOut === null) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } if (empty($payOut['ok'])) { $http = (int)($payOut['http'] ?? 400); unset($payOut['http']); api_out($payOut, $http); } api_out($payOut); } /** * OPERATOR_GO §4 / E2a: KING seeds real fillable SELL-ask ladder on the book. * Not free giveaway — visitors BUY by filling these asks (NOTE settle_ref off-chain). * Each treasury SELL auto-arms C2 half-BID. Lords cannot call (require_faucet_auth). */ if ($api === 'admin_treasury_ladder' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); require_faucet_auth(); // Pre-read treasury material OUTSIDE chain lock (deadlock law; same as C2 half-bid). $tsec = treasury_secret(); $taddr = strtolower(addr_from_seed($tsec)); $amtIn = trim((string)($_POST['amount'] ?? '')); $amtMicros = null; if ($amtIn !== '') { $amtMicros = parse_amt($amtIn); if ($amtMicros === null || dec_cmp($amtMicros, '1') < 0) { api_out(['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)'], 400); } } $ladder = nst_treasury_ask_ladder($tsec, $taddr, $amtMicros); if (empty($ladder['ok'])) { $http = 400; $err = (string)($ladder['err'] ?? ''); if ($err === 'disk' || str_contains($err, 'Could not save') || str_contains($err, 'half-bid failed')) { $http = 500; } api_out($ladder, $http); } // Ladder half-BID notices: AFTER money/book writes, fail-open. try { $hbList = is_array($ladder['half_bids'] ?? null) ? $ladder['half_bids'] : []; $ordList = is_array($ladder['orders'] ?? null) ? $ladder['orders'] : []; foreach ($hbList as $i => $hb) { if (!is_array($hb)) continue; $sell = is_array($ordList[$i] ?? null) ? $ordList[$i] : null; nst_inbox_notify_half_bid($sell, $hb); } } catch (Throwable $e) { // never abort a committed ladder } api_out($ladder); } /** Credit a donate: gift NSU to addr (mid×k or direct), 50/50 profit/ad-bid accounting */ if ($api === 'admin_donate_credit' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); // Donate gifts pull from treasury faucet — KING only (CONTRACT §3). require_faucet_auth(); $to = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['to'] ?? ''))); if (strlen($to) !== 64) api_out(['ok' => false, 'err' => 'Bad destination address — need a 64-char hex wallet address'], 400); $txid = substr(trim((string)($_POST['txid'] ?? '')), 0, 128); $asset = strtoupper(substr(trim((string)($_POST['asset'] ?? 'BTC')), 0, 16)); $ext = trim((string)($_POST['ext_amount'] ?? '')); $direct = trim((string)($_POST['nsu_amount'] ?? '')); $e = load_economy(); $k = (string)($e['donate_k'] ?? NST_DONATE_K); $mid = book_mid_micros(read_chain()); if ($direct !== '') { $nsu = parse_amt($direct); } else { $nsu = donate_nsu_from_external($ext !== '' ? $ext : '0', $mid, $k); } if ($nsu === null || dec_cmp($nsu, '1') < 0) { api_out(['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)', 'mid_fmt' => $mid !== null ? fmt_amt($mid) : null, 'k' => $k], 400); } $r = economy_credit_donate($to, $nsu, $txid, $asset, $ext !== '' ? $ext : $direct); if (empty($r['ok'])) api_out($r, isset($r['err']) && str_contains((string)$r['err'], 'treasury') ? 503 : 400); $r['mid_fmt'] = $mid !== null ? fmt_amt($mid) : null; $r['k'] = $k; api_out($r); } /** Extract profit pile → pay operator addr from treasury; reduce profit accounting */ if ($api === 'admin_extract_profit' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); // Treasury debit = faucet authority (KING only). LORD spends funded wallet, never treasury.secret. require_faucet_auth(); $to = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['to'] ?? ''))); $micros = parse_amt(trim((string)($_POST['amount'] ?? ''))); if (strlen($to) !== 64) api_out(['ok' => false, 'err' => 'Bad destination address — need a 64-char hex wallet address'], 400); if ($micros === null || dec_cmp($micros, '1') < 0) api_out(['ok' => false, 'err' => 'Amount must be a positive number (at least 0.000001 NSU)'], 400); $sec = treasury_secret(); $from = addr_from_seed($sec); $ts = time(); $nonce = bin2hex(random_bytes(8)); $memo = 'profit:extract'; $body = implode('|', ['transfer', '1', $from, $to, $micros, $nonce, (string)$ts, $memo]); $row = [ 'type' => 'transfer', 'ver' => 1, 'from' => $from, 'to' => $to, 'amount' => $micros, 'amount_fmt' => fmt_amt($micros), 'nonce' => $nonce, 'ts' => $ts, 'memo' => $memo, 'sig' => mac_sign($sec, $body), 'kind' => 'profit_extract', ]; // Chain lock covers treasury debit; re-load economy under lock so pile cannot double-extract. $exOut = nst_with_chain_lock(function (array $chain, callable $append) use ($from, $to, $micros, $nonce, $row) { $e = load_economy(); $pile = (string)($e['profit_micros'] ?? '0'); if (dec_cmp($pile, $micros) < 0) { return ['ok' => false, 'http' => 400, 'err' => 'profit pile too small', 'have_fmt' => fmt_amt($pile)]; } foreach ($chain as $r) { if (($r['type'] ?? '') === 'transfer' && ($r['from'] ?? '') === $from && ($r['nonce'] ?? '') === $nonce) { return ['ok' => false, 'http' => 400, 'err' => 'That transfer was already sent (nonce replay) — use a new nonce']; } } $bal = balances($chain); $have = $bal[$from] ?? '0'; if (dec_cmp($have, $micros) < 0) { return ['ok' => false, 'http' => 400, 'err' => 'Treasury does not have enough free NSU for that — try a smaller amount', 'have_fmt' => fmt_amt($have)]; } if (!$append($row)) { return ['ok' => false, 'http' => 500, 'err' => 'Could not save to the server — try again in a moment']; } $e['profit_micros'] = dec_sub($pile, $micros); save_economy($e); return [ 'ok' => true, 'tx' => $row, 'profit_fmt' => fmt_amt((string)$e['profit_micros']), 'treasury_bal_fmt' => fmt_amt(dec_sub($have, $micros)), ]; }); if ($exOut === null) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } if (empty($exOut['ok'])) { $http = (int)($exOut['http'] ?? 400); unset($exOut['http']); api_out($exOut, $http); } api_out($exOut); } if ($api === 'economy') { $e = load_economy(); $mid = book_mid_micros(read_chain()); $active = array_values(array_filter($e['bids'] ?? [], fn($b) => !empty($b['active']))); $withCreative = 0; foreach ($active as $b) { if (!empty($b['creative_url']) || !empty($b['creative_file'])) $withCreative++; } api_out([ 'ok' => true, 'profit_fmt' => fmt_amt((string)($e['profit_micros'] ?? '0')), 'ad_bid_fmt' => fmt_amt((string)($e['ad_bid_micros'] ?? '0')), 'donate_k' => (string)($e['donate_k'] ?? NST_DONATE_K), 'mid_fmt' => $mid !== null ? fmt_amt($mid) : null, 'epoch_n' => (int)(load_meta()['epoch_n'] ?? 1), 'donate_addrs' => [ 'btc' => NST_DONATE_BTC, 'xmr' => NST_DONATE_XMR, 'ltc' => NST_DONATE_LTC, ], 'active_bids' => $active, 'bids_with_creative' => $withCreative, 'house_chance_pct' => NST_HOUSE_AD_CHANCE, 'ad_board_micros' => (string)array_reduce($active, static function ($n, $b) { return dec_add($n, (string)($b['stake_micros'] ?? '0')); }, '0'), ]); } /** Public catalogue of in-file purpose brains (no secrets; ethos visibility). */ if ($api === 'brains') { api_out([ 'ok' => true, 'site' => 'trade', 'version' => NST_VERSION, 'doctrine' => 'tiny in-file specialists only; no external LLM APIs; fail open when unsure', 'brains' => [ ['id' => 'memo_risk', 'job' => 'scam tokens in transfer memos', 'mode' => 'soft flag'], ['id' => 'order_sanity', 'job' => 'side/amount/price sanity', 'mode' => 'soft flag'], ['id' => 'pair_sane', 'job' => 'book pair format NSU/NOTE…', 'mode' => 'soft flag'], ['id' => 'wipe_copy', 'job' => 'balances-only year wipe banner copy', 'mode' => 'coherence'], ['id' => 'ad_png', 'job' => 'PNG magic+IHDR geometry + byte budget', 'mode' => 'hard reject + soft flag'], ['id' => 'href_risk', 'job' => 'http(s) click-through schemes', 'mode' => 'hard scheme + soft phishing'], ['id' => 'year_wipe_rekey', 'job' => 'rotate panel password into vault on epoch roll', 'mode' => 'automatic'], ['id' => 'house_ads', 'job' => 'weighted house discovery when empty + luck slice', 'mode' => 'allotted random'], ['id' => 'bootstrap_spread', 'job' => 'loose BUY@0.5/SELL@1.5 around 1.0 if zero open orders', 'mode' => 'seed discovery only'], ['id' => 'panel_seed', 'job' => 'controlpanel unlock via site wallet seed (treasury)', 'mode' => 'no recovery'], ], 'contact' => NST_CONTACT_EMAIL, 'ad_png_rules' => [ 'max_bytes' => NST_AD_MAX_PNG, 'min_w' => NST_AD_MIN_W, 'max_w' => NST_AD_MAX_W, 'min_h' => NST_AD_MIN_H, 'max_h' => NST_AD_MAX_H, 'formats' => ['PNG only'], ], 'wipe' => 'ONLY user wallet balances survive yearly wipe; profit/ads/orders burn', ]); } /** Weighted random creative for site-local adboard (paid bids + house discovery) */ /* ───────────────────────────────────────────────────────────────────────── * AD STAKE — the sink. Spend NSU, get impression weight. * * WEIGHTED LOTTERY, NOT AN AUCTION. ad_pick_weighted() gives every staker a * share of impressions proportional to their stake. A winner-take-all * auction would hand the slot to the largest wallet and show everyone else * nothing, which is a platform deciding who gets seen — refused by the * ethos. Here a 1% stake buys 1% of impressions and nobody is excluded. * * PROPORTIONALITY IS EMERGENT. There is no reserve and no index to total * supply. Stakers bid what NSU is worth to them; if everyone is richer * everyone stakes more and the shares are unchanged. A formula would be a * guess, the stake distribution is a measurement. * * There is no price at all - not a floor, not a rate, not a reserve. Your * share is your spend over the board's total, so nobody can be priced out: * a small buy gets a small share, never nothing. And because a buy purchases * the REMAINDER OF THE EPOCH, the same coins are worth less the later they * are spent. That decay is the board's price signal and it is emergent - * nobody sets it, and no formula has to be maintained to keep it honest. * * The stake CIRCULATES: payer -> lord (95%) + King's tax (5%). Nothing is * minted here. Ads buy their own real estate in a dedicated slot; they never * buy rank in someone else's organic results. * ───────────────────────────────────────────────────────────────────────── */ if ($api === 'ad_stake' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); $actor = nst_actor('from'); $from = $actor['addr']; $micros = parse_amt(trim((string)($_POST['amount'] ?? ''))); if ($micros === null || dec_cmp($micros, '1') < 0) { api_out(['ok' => false, 'err' => 'Stake must be a positive number (at least 0.000001 NSU)'], 400); } $href = trim((string)($_POST['href'] ?? '')); if ($href !== '' && !preg_match('#^https?://#i', $href)) { api_out(['ok' => false, 'err' => 'Link must start with http:// or https://'], 400); } $label = substr(trim((string)($_POST['label'] ?? '')), 0, 80); $creativeUrl = trim((string)($_POST['creative_url'] ?? '')); if ($creativeUrl !== '' && !preg_match('#^https?://#i', $creativeUrl)) { api_out(['ok' => false, 'err' => 'Creative URL must start with http:// or https://'], 400); } $nonce = substr(trim((string)($_POST['nonce'] ?? bin2hex(random_bytes(8)))), 0, 64); $ts = time(); /* Refuse a stake with nothing to show. Taking payment for a board slot * that can never render anything is the dishonest half of the bug fixed * in ad_pick_weighted(); this is the other half. Adding to an existing * bid is fine - that one already carries its creative. */ if ($label === '' && $creativeUrl === '') { $hasExisting = false; foreach ((array)(load_economy()['bids'] ?? []) as $b) { if (is_array($b) && ($b['owner'] ?? '') === $from && (($b['label'] ?? '') !== '' || ($b['creative_url'] ?? '') !== '' || ($b['creative_file'] ?? '') !== '')) { $hasExisting = true; break; } } if (!$hasExisting) { api_out(['ok' => false, 'err' => 'Give the ad something to show — a label, or a creative image URL'], 400); } } $lord = nst_ensure_operator_addr(); $king = nst_treasury_addr_safe(); if ($lord === '' && $king === '') { api_out(['ok' => false, 'err' => 'This crop has no operator wallet yet'], 503); } if ($lord === '') $lord = $king; if ($king === '') $king = $lord; if ($from === $lord) { api_out(['ok' => false, 'err' => 'The operator cannot stake into their own board'], 400); } $split = nst_fee_split($micros); $body = implode('|', ['ad_stake', '1', $from, $micros, $nonce, (string)$ts]); $auth = nst_auth_sig($actor, $body); $res = nst_with_chain_lock(function (array $chain, callable $append) use ( $from, $lord, $king, $micros, $split, $nonce, $ts, $auth, $href, $label, $creativeUrl ) { $bal = balances($chain); /* SPEND AGAINST FREE NSU, NOT THE RAW BALANCE. * Coins backing an open sell order are reserved (reserved_nsu), and * every other spend path - transfer, the buy leg, the faucet - tests * available_nsu() for exactly that reason. This one tested the raw * balance, so an advertiser could list 100 NSU for sale and then stake * the same 100 on the board: the order stays on the book quoting coins * that are no longer there, and it fails at fill time for whoever * takes it. Money spent twice in intent, and the loss lands on a third * party who did nothing wrong. */ $have = (string)($bal[$from] ?? '0'); $av = available_nsu($bal, $chain, $from); if (dec_cmp($av, $micros) < 0) { return ['ok' => false, 'err' => 'Not enough free NSU — you hold ' . fmt_amt($have) . ', free ' . fmt_amt($av) . ' (the rest backs open sell orders)']; } $outs = []; if (dec_cmp($split['lord'], '0') > 0) $outs[] = ['addr' => $lord, 'amount' => $split['lord']]; if (dec_cmp($split['king'], '0') > 0 && $king !== $lord) { $outs[] = ['addr' => $king, 'amount' => $split['king']]; } elseif (dec_cmp($split['king'], '0') > 0) { /* lord IS king on an unrented crop: one leg, still the whole fee */ $outs[0]['amount'] = dec_add($outs[0]['amount'], $split['king']); } $row = [ 'type' => 'ad_stake', 'ver' => 1, 'from' => $from, 'amount' => $micros, 'amount_fmt' => fmt_amt($micros), 'outputs' => $outs, 'lord_micros' => $split['lord'], 'king_tax_micros' => $split['king'], 'king_tax_ppm' => NST_KING_TAX_PPM, 'nonce' => $nonce, 'ts' => $ts, 'sig' => $auth['sig'], 'sig_mode' => $auth['sig_mode'], 'note' => 'ad stake: fee circulates (lord + King tax). No mint. Buys impression ' . 'weight in the dedicated ad slot only - never rank in organic results.', ]; if (!$append($row)) return ['ok' => false, 'err' => 'Could not save to the server — try again in a moment']; return ['ok' => true]; }); if (empty($res['ok'])) { api_out(['ok' => false, 'err' => (string)($res['err'] ?? 'stake failed')], 400); } /* Standing lives in economy.json and is therefore ANNUAL - the board * clears at the yearly wipe like every other superstructure claim, while * the coins that were spent stay where they were paid. * * NOTHING IS BURNED AT THE WIPE, because nothing is being held. The buyer * paid for a share of the epoch and received exactly that; when the epoch * ends there is no leftover balance to lose. This was briefly a real * problem while ad spend was a prepaid meter - unspent credit would have * been destroyed at the roll, which is taking money for undelivered goods * - and reverting to the share model removed the problem rather than * managing it. A design with no refund path is honest only when there is * also nothing left over to refund. */ $e = load_economy(); if (!isset($e['bids']) || !is_array($e['bids'])) $e['bids'] = []; $bidId = hash('sha256', $from . '|' . $ts . '|' . $micros . '|' . $nonce); $merged = false; foreach ($e['bids'] as $i => $b) { if (!is_array($b) || ($b['owner'] ?? '') !== $from) continue; $e['bids'][$i]['stake_micros'] = dec_add((string)($b['stake_micros'] ?? '0'), $micros); $e['bids'][$i]['stake_fmt'] = fmt_amt((string)$e['bids'][$i]['stake_micros']); $e['bids'][$i]['active'] = true; $e['bids'][$i]['ts'] = $ts; if ($href !== '') $e['bids'][$i]['href'] = $href; if ($label !== '') $e['bids'][$i]['label'] = $label; if ($creativeUrl !== '') $e['bids'][$i]['creative_url'] = $creativeUrl; $bidId = (string)($b['id'] ?? $bidId); $merged = true; break; } if (!$merged) { $e['bids'][] = [ 'id' => $bidId, 'owner' => $from, 'stake_micros' => $micros, 'stake_fmt' => fmt_amt($micros), 'ts' => $ts, 'active' => true, 'from_donate' => false, 'href' => $href, 'label' => $label, 'creative_url' => $creativeUrl, 'creative_file' => '', ]; } $e['ad_bid_micros'] = dec_add((string)($e['ad_bid_micros'] ?? '0'), $micros); if (count($e['bids']) > 200) $e['bids'] = array_slice($e['bids'], -200); save_economy($e); $total = '0'; foreach ($e['bids'] as $b) { if (!empty($b['active'])) $total = dec_add($total, (string)($b['stake_micros'] ?? '0')); } $mine = '0'; foreach ($e['bids'] as $b) { if (($b['id'] ?? '') === $bidId) $mine = (string)($b['stake_micros'] ?? '0'); } api_out([ 'ok' => true, 'bid_id' => $bidId, 'staked_fmt' => fmt_amt($micros), 'your_stake_fmt' => fmt_amt($mine), 'board_total_fmt' => fmt_amt($total), 'share_pct' => (dec_cmp($total, '0') > 0) ? round(((float)$mine / max(1e-9, (float)$total)) * 100, 2) : 0, 'lord_fmt' => fmt_amt($split['lord']), 'king_tax_fmt' => fmt_amt($split['king']), /* THE DISCLOSURE. A buyer is handed the actual odds, the actual * surface those odds apply to, and the actual end date - before the * word "share" has to be taken on trust. */ 'display_chance_pct' => (dec_cmp($total, '0') > 0) ? round(((float)$mine / max(1e-9, (float)$total)) * 100, 2) : 0, 'house_chance_pct' => NST_HOUSE_AD_CHANCE, 'viewer_chance_pct' => (dec_cmp($total, '0') > 0) ? round(((float)$mine / max(1e-9, (float)$total)) * (100 - NST_HOUSE_AD_CHANCE), 2) : 0, 'empire_domains' => NST_EMPIRE_DOMAINS, 'refundable' => false, 'runs_until' => 'epoch_wipe', 'runs_until_note' => 'Runs until the yearly wipe, then the whole board clears.', 'msg' => 'Ad live. Your ' . fmt_amt($mine) . ' NSU is a share of the paid slot: about ' . ((dec_cmp($total, '0') > 0) ? round(((float)$mine / max(1e-9, (float)$total)) * (100 - NST_HOUSE_AD_CHANCE), 2) : 0) . '% of views will show your ad (the rest is other buyers, plus a small house slice). ' . 'Coins are spent, not held. No refunds. Runs until the yearly wipe. ' . 'More you buy raises your chance; more others buy lowers it.', ]); } if ($api === 'ad_pick') { /* CORS on THIS endpoint only. Sister crops (market, work, date...) render * the empire's ad board in their own slot, and a browser fetch across * hosts needs it. Safe here and nowhere else: ad_pick is public, * read-only, unauthenticated and returns no secrets. Never widen this to * api_out() generally - transfer, ad_stake and every admin_* route take a * seed in the POST body and must stay same-origin. */ header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET'); $b = ad_pick_weighted(); if (!$b) { api_out(['ok' => true, 'empty' => true, 'msg' => 'no creatives']); } $house = !empty($b['house']); /* NOTHING IS CHARGED HERE. An ad buys a SHARE of the board for the rest * of the epoch, not a meter of impressions - see the ad_stake block. So * serving is a pure read: no lock, no economy.json write, no disk churn * on a page that is already the busiest path in the empire. */ /* NEVER HAND A CLIENT A THIRD-PARTY IMAGE URL. * * This used to emit the advertiser's raw creative_url, which the adboard * then rendered as . Every viewer's browser would fetch it * directly, so the advertiser's server saw the IP and User-Agent of every * person who was shown the ad - on every impression, with no disclosure * and no way to refuse. That is a tracking pixel wearing an ad's clothes, * and ethos 11 names hidden trackers outright. It got worse the moment * the slot went cross-site, because one bid then harvests the visitors of * every crop in the empire. * * So only creatives THIS server holds are served, through ?api=ad_img off * local disk. A viewer talks to the empire and to nobody else. * * creative_url is still recorded on the bid (auditable, and the operator * can see what was submitted); it is simply never turned into a request * the visitor makes. Restoring remote creatives properly means fetching * and caching them server-side - which needs SSRF protection, a size cap * and content-type validation - not re-emitting the URL. */ $img = null; if (!$house && !empty($b['creative_file'])) { $img = ['type' => 'api', 'src' => '?api=ad_img&id=' . urlencode((string)$b['id']) . '&v=' . (int)($b['creative_ts'] ?? 0)]; } $pngBrain = $b['png_brain'] ?? null; api_out([ 'ok' => true, 'empty' => false, 'house' => $house, 'label' => $b['label'] ?? '', 'bid_id' => $b['id'] ?? '', 'stake_fmt' => $house ? 'house' : ($b['stake_fmt'] ?? fmt_amt((string)($b['stake_micros'] ?? '0'))), 'img' => $img, 'href' => $b['href'] ?? '', 'png_w' => $b['png_w'] ?? ($pngBrain['w'] ?? null), 'png_h' => $b['png_h'] ?? ($pngBrain['h'] ?? null), 'png_bytes' => $b['png_bytes'] ?? ($pngBrain['bytes'] ?? null), 'png_flag' => !empty($pngBrain['flag']), 'png_notes' => $pngBrain['notes'] ?? [], 'recruit' => NST_CONTACT_EMAIL, ]); } if ($api === 'ad_img') { $id = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_GET['id'] ?? ''))); if (strlen($id) < 16) { http_response_code(404); exit; } $path = ads_dir() . DIRECTORY_SEPARATOR . $id . '.png'; if (!is_file($path)) { http_response_code(404); exit; } header('Content-Type: image/png'); header('Cache-Control: public, max-age=300'); readfile($path); exit; } /** Attach creative to a bid (URL and/or PNG base64). Site-local board only. */ if ($api === 'admin_ad_creative' && $_SERVER['REQUEST_METHOD'] === 'POST') { nst_require_writes_unfrozen(); require_admin(); $id = preg_replace('/[^a-f0-9]/', '', strtolower((string)($_POST['bid_id'] ?? ''))); $url = trim((string)($_POST['creative_url'] ?? '')); $href = trim((string)($_POST['href'] ?? '')); $b64 = (string)($_POST['png_base64'] ?? ''); if (strlen($id) < 16) api_out(['ok' => false, 'err' => 'bad bid_id'], 400); $brains = []; if ($href !== '') { $hrefBrain = nst_brain_href_risk($href); $brains['href_risk'] = $hrefBrain; if (empty($hrefBrain['ok'])) { api_out(['ok' => false, 'err' => (string)($hrefBrain['err'] ?? 'bad href'), 'brains' => $brains], 400); } } if ($url !== '') { $urlBrain = nst_brain_href_risk($url); $brains['creative_url'] = $urlBrain; if (empty($urlBrain['ok'])) { api_out(['ok' => false, 'err' => (string)($urlBrain['err'] ?? 'bad creative_url'), 'brains' => $brains], 400); } } $pngMeta = null; $e = load_economy(); $found = false; foreach ($e['bids'] as &$b) { if (($b['id'] ?? '') !== $id) continue; $found = true; if ($url !== '') { $b['creative_url'] = substr($url, 0, 500); } if ($href !== '') { $b['href'] = substr($href, 0, 500); if (!empty($brains['href_risk']['flag'])) { $b['href_flag'] = $brains['href_risk']; } } if ($b64 !== '') { if (str_starts_with($b64, 'data:image/png;base64,')) { $b64 = substr($b64, strlen('data:image/png;base64,')); } elseif (str_starts_with($b64, 'data:image/')) { api_out(['ok' => false, 'err' => 'PNG only (no JPEG/GIF/WebP data URLs)', 'brains' => $brains], 400); } $bin = base64_decode($b64, true); if ($bin === false) { api_out(['ok' => false, 'err' => 'bad base64', 'brains' => $brains], 400); } $pngBrain = nst_brain_ad_png($bin); $brains['ad_png'] = $pngBrain; if (empty($pngBrain['ok'])) { api_out(['ok' => false, 'err' => (string)($pngBrain['err'] ?? 'png reject'), 'brains' => $brains], 400); } $dir = ads_dir(); $fp = $dir . DIRECTORY_SEPARATOR . $id . '.png'; if (file_put_contents($fp, $bin, LOCK_EX) === false) { api_out(['ok' => false, 'err' => 'Could not save the ad image — try again in a moment', 'brains' => $brains], 500); } @chmod($fp, 0644); $b['creative_file'] = $id . '.png'; $b['png_w'] = $pngBrain['w'] ?? null; $b['png_h'] = $pngBrain['h'] ?? null; $b['png_bytes'] = $pngBrain['bytes'] ?? strlen($bin); $b['png_brain'] = $pngBrain; $pngMeta = $pngBrain; // prefer local file over url when both set } $b['creative_ts'] = time(); break; } unset($b); if (!$found) api_out(['ok' => false, 'err' => 'bid not found', 'brains' => $brains], 404); save_economy($e); $msg = 'creative set'; if ($pngMeta && !empty($pngMeta['flag'])) { $msg .= ' (soft flag: ' . implode(',', $pngMeta['notes'] ?? []) . ')'; } api_out(['ok' => true, 'msg' => $msg, 'bid_id' => $id, 'png' => $pngMeta, 'brains' => $brains]); } if ($api === 'admin_backup' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); $wantSecrets = !empty($_POST['include_secrets']); if ($wantSecrets) { // treasury.secret in export is KING-only (CONTRACT §3). require_faucet_auth(); } $chain = read_chain(); $mods = load_mods(); $meta = load_meta(); api_out([ 'ok' => true, 'version' => NST_VERSION, 'exported_ts' => time(), 'meta' => $meta, 'epoch' => epoch_info(), 'mods' => $mods, 'chain_len' => count($chain), 'chain' => $chain, 'treasury_addr' => $taddr, 'include_secrets' => $wantSecrets, 'treasury_secret' => $wantSecrets ? treasury_secret() : null, 'admin_pass' => null, 'admin_pass_note' => 'Leftover admin password files are unlinked. Product door is site seed.', ]); } /** * Public rent quote (P3a+P3b / NSU_VIABILITY E3 quote half). * GET or POST ?api=rent_quote&site=trade|com|… — no auth, no chain write, no bind. * Persists open quote to data/rent_quotes.json under flock (P3b). */ if ($api === 'rent_quote') { $siteQ = (string)($_GET['site'] ?? $_POST['site'] ?? $_REQUEST['site'] ?? 'trade'); $q = nst_rent_quote_public($siteQ, $taddr); $http = (int)($q['http'] ?? 200); unset($q['http']); api_out($q, $http > 0 ? $http : 200); } /** * Lookup durable quote (P3b). Open or paid. * GET ?api=rent_quote_get"e_id= */ if ($api === 'rent_quote_get') { $qid = (string)($_GET['quote_id'] ?? $_POST['quote_id'] ?? $_REQUEST['quote_id'] ?? ''); $row = nst_rent_quote_get($qid); if ($row === null) { api_out(['ok' => false, 'err' => 'quote not found or expired', 'settle' => false], 404); } api_out([ 'ok' => true, 'quote' => $row, 'durable' => true, 'paid' => ((string)($row['status'] ?? '') === 'paid'), 'law' => 'Lookup only — pay via transfer to treasury with exact memo+amount.', ]); } /** * P3c: settle rent from chain evidence (scan transfers). Optional quote_id filter. * GET|POST ?api=rent_settle"e_id= — no auth; first-paid-wins already on transfer path. */ if ($api === 'rent_settle') { $qidS = (string)($_GET['quote_id'] ?? $_POST['quote_id'] ?? $_REQUEST['quote_id'] ?? ''); $scan = nst_rent_settle_scan($qidS !== '' ? $qidS : null); api_out($scan, !empty($scan['settled']) ? 200 : 200); } /** Rent grant path retired — always 410 after KING auth. No treasury credit. */ if ($api === 'admin_rent_claim' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_faucet_auth(); api_out([ 'ok' => false, 'err' => 'rent grant path retired', 'law' => 'Rent is payment TO treasury only (not a grant FROM faucet). ' . 'Server no longer spawns LORD seeds or credits renters from treasury. ' . 'Public quotes: ?api=rent_quote&site=… (durable) · settle/bind still next residual.', 'lord_rent_grant' => 'retired', 'next' => 'rent_quote durable (live) · pay-bind settle (not shipped)', ], 410); } /** Public: empire setup filled? Sister crops / pack gate may poll this (no secrets). */ if ($api === 'empire_setup_status') { $st = nst_empire_setup_state(); $book = nst_empire_setup_book_census(); api_out([ 'ok' => true, 'site' => 'trade', 'filled' => !empty($st['filled']), 'version' => (int)($st['version'] ?? 1), 'filled_ts' => $st['filled_ts'] ?? null, 'ladder_posted' => $st['ladder_posted'] ?? null, 'book' => $book, 'setup_url' => NST_EMPIRE_SETUP_PUBLIC_URL, 'console_path' => '/?empire_setup=1', 'law' => 'While filled=false, public faces redirect to empire setup console', ]); } /** * KING: mark empire setup filled/unfilled (lifts or restores visitor redirect). * Prefer ladder first; allows mark even if ladder empty (operator responsibility). */ if ($api === 'empire_setup_mark_filled' && $_SERVER['REQUEST_METHOD'] === 'POST') { require_faucet_auth(); $want = (string)($_POST['filled'] ?? '1'); $filled = !($want === '0' || strtolower($want) === 'false' || $want === 'no'); $book = nst_empire_setup_book_census(); $state = [ 'filled' => $filled, 'filled_ts' => time(), 'ladder_posted' => (int)($book['treasury_ladder'] ?? 0), 'note' => $filled ? 'operator marked empire filled' : 'operator unfilled — console gate active', ]; if (!nst_empire_setup_write($state)) { api_out(['ok' => false, 'err' => 'Could not save to the server — try again in a moment'], 500); } api_out([ 'ok' => true, 'filled' => $filled, 'book' => $book, 'msg' => $filled ? 'Empire filled — public product faces open on this host' : 'Empire unfilled — visitors redirected to setup console', ]); } /* RR334 P1-TRADE-UNKNOWN-API-PLAIN: bare "unknown api" → plain route hints (no face HTML) */ api_out([ 'ok' => false, 'err' => 'Unknown trade API — use selfhash, book, state, balance, transfer, order, cancel, fill, faucet (410 retired), or open the Trade face', 'api' => $api, ], 404); } /* -------- Empire setup console (?empire_setup=1 / /setup) -------- */ $wantSetup = isset($_GET['empire_setup']) || isset($_GET['setup']) || (isset($_SERVER['REQUEST_URI']) && preg_match('#/setup/?(\?|$)#', (string)$_SERVER['REQUEST_URI'])); if ($wantSetup && (string)($_GET['api'] ?? $_POST['api'] ?? '') === '') { nst_render_empire_setup_console(); } /* -------- /controlpanel — site wallet seed unlock (no recovery) -------- */ $wantPanel = isset($_GET['controlpanel']) || (isset($_SERVER['REQUEST_URI']) && preg_match('#/controlpanel/?(\?|$)#', (string)$_SERVER['REQUEST_URI'])); if ($wantPanel && (string)($_GET['api'] ?? $_POST['api'] ?? '') === '') { header('Content-Type: text/html; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); $v = NST_VERSION; $panelSeed = ''; $unlocked = false; $loginErr = ''; // POST seed → derive addr; unlock if treasury (owner) or economy.operator_addr (rent later) if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { $panelSeed = norm_seed((string)($_POST['seed'] ?? '')); if ($panelSeed === '') { $loginErr = 'Paste this site\'s wallet seed (12 words). No recovery.'; } elseif (!panel_seed_ok($panelSeed)) { $loginErr = 'Seed does not unlock this site panel (must match site wallet / treasury).'; $panelSeed = ''; } else { $unlocked = true; } } $esc = static function (string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }; echo ''; echo 'Trade Control Panel
'; echo '

DNA · TRADE · money/buy · orange burn

'; echo '

Nosignup.Trade · Control Panel

'; echo '

Paste this site\'s operator seed (no recovery). Fresh genesis: panel = data/site.seed (not mint); KING faucet = data/treasury.secret (only minter). ' . 'After public rent pay (visitor pays treasury exact quote), payer key becomes LORD for the epoch — no server-spawned LORD seed. ' . 'UTTER control of THIS crop (including replacing this file). Not OS root. ' . 'Genesis supply lives in treasury (24_000_000 NSU KING faucet). ' . 'Renters must not receive treasury secret. Yearly wipe: ONLY user balances survive; book re-seeds loose bootstrap spread; superstructure burns.

'; echo '
'; echo ''; echo ''; echo ''; if ($loginErr !== '') { echo '
' . $esc($loginErr) . '
'; } echo '

Panel: data/site.seed + vault SITE_WALLET_SEED.txt. KING (mint only): data/treasury.secret + vault KING_FAUCET_SEED.txt. After rent: LORD = payer key (economy.operator_addr). No recovery desk. No password product path.

'; echo '
Version-
'; echo '
Treasury (master faucet)-
'; echo '
Profit pile-
'; echo '
Ad bid stake-
'; echo '
Book mid / k-
'; echo '
Value note-
'; echo '
Chain / epoch-
'; echo '
Vault hint-
'; echo '

'; echo '

NSU value (this server owner only)

'; echo '

Value authority = you (owner of THIS crop). Set donate k (gift ≈ external÷mid×k) and a public value note. Mirrors are free tributaries — no value authority. Market P = book mid; donate buyback arms at P/2.

'; echo ''; echo '
'; echo '

Year-end wipe checklist (operator)

'; echo '

ONLY wallet balances survive. Profit/ads/orders burn. Site wallet seed does NOT rotate (still your paper). Bootstrap book re-seeds if empty. Tick boxes local only.

'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo ''; echo '

Rent / LORD (grant path retired · quote live)

'; echo '

RETIRED: treasury→LORD market grant + server-spawned LORD seed are gone (atomic P2). ' . 'P3a–c live: ?api=rent_quote · durable store · pay treasury with exact memo+amount · ' . 'first-paid-wins binds operator_addr to payer key (no server LORD seed). ' . 'Lookup rent_quote_get · rescan rent_settle. ' . 'Failed rent pays (wrong amount / already paid / expired / …) auto-refund to payer (P3d). ' . 'Faucet start narrative still ' . (int)NST_KING_FAUCET_START_NSU . ' NSU. Renters never mint. ' . 'Never email treasury.secret.

'; echo ''; echo '
Grant path retired · try ?api=rent_quote&site=trade (no settle yet)
'; echo ''; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo ''; echo '

Credit donate → NSU gift (50/50 split)

'; echo '

Gifts NSU from treasury master faucet (nsu_amount OR ext_amount÷mid×k), then 50% profit / 50% ad-bid + buyback at P/2. Free faucet claims do not use this path.

'; echo ''; echo ''; echo '
'; echo '

Extract profit

'; echo '
'; echo '

Ad creative (site-local weighted board)

'; echo '

PNG only. Max ' . NST_AD_MAX_PNG . 'b · ' . NST_AD_MIN_W . '-' . NST_AD_MAX_W . '×' . NST_AD_MIN_H . '-' . NST_AD_MAX_H . ' px. House discovery fills empty boards. Invite: ' . $esc(NST_CONTACT_EMAIL) . '

'; echo ''; echo ''; echo '
'; echo '

Treasury pay (master faucet)

'; echo '
'; echo '

Source (utter control)

Load → edit → save replaces this site file. Broken save can kill the site until SSH restore from .bak.

'; echo ''; echo '
'; echo '

Virgin pack (KING)

'; echo '

A deployable copy of this whole empire - the ten sites, Deploy.bat and NSU-DNA - with no seeds, no vault, no data. Stand up a sister empire from this one without needing the machine you first deployed from. Requires the KING treasury seed, not a lord panel seed.

'; echo '
'; echo '

Sealed wallet inbox (KING · experimental)

'; echo '

Mail ≠ mint. KING hand-seal = true client-encrypt E2E path. Auto fill/half-BID/rent notices = server-composed public facts (encrypted for delivery, NOT private E2E). ' . 'Server stores opaque ciphertext only — paste plaintext only in this browser; encryption runs client-side when WebCrypto works. ' . 'Not social mail. Not proven e2e. Blobs burn at yearly wipe / TTL.

'; echo ''; echo '
'; echo '

Backup

'; echo '

← back to trade

'; // Seed stays in memory for API auth only after successful POST unlock (not echoed into form). echo ''; exit; } /* -------- HTML -------- */ // Redirect before any genesis, epoch, conservation, or economy mutation. nst_empire_setup_maybe_redirect(); $chainSsr = read_chain(); $taddr = nst_treasury_addr_safe(); if ($taddr === '') { http_response_code(503); header('Content-Type: text/plain; charset=UTF-8'); header('Cache-Control: private, no-store, max-age=0, must-revalidate'); header('X-Content-Type-Options: nosniff'); echo "Trade is not initialized. Open the empire setup console.\n"; exit; } $tbal = fmt_amt(balances($chainSsr)[$taddr] ?? '0'); // Pre-ENTER shell is read-only: expose the last recorded conservation status only. $nstConservePub = nst_conservation_public_view(); $nstFrozen = !empty($nstConservePub['frozen']); // SSR market P + buyback P/2 (same helpers as ?api=state) so strip is honest before JS $ssrP = book_mid_micros($chainSsr); $ssrP2 = nst_buyback_price_micros($chainSsr); $ssrP_fmt = $ssrP !== null ? fmt_amt($ssrP) : '-'; $ssrP2_fmt = $ssrP2 !== null ? fmt_amt($ssrP2) : '-'; $ssrBoot = false; foreach (open_orders($chainSsr) as $oSsr) { if (($oSsr['kind'] ?? '') === 'bootstrap_spread') { $ssrBoot = true; break; } } $selfHash = @hash_file('sha256', __FILE__) ?: 'unavailable'; $htmlNonce = base64_encode(random_bytes(18)); header('Content-Type: text/html; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: private, no-store, max-age=0, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); header('Vary: Cookie'); header('Referrer-Policy: no-referrer'); header('X-Frame-Options: DENY'); header('Cross-Origin-Resource-Policy: same-origin'); header("Content-Security-Policy: default-src 'self'; script-src 'nonce-" . $htmlNonce . "'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"); ?> Nosignup.Trade · money/buy rails >
Nosignup.Trade money/buy rails
P RESET IN d
LOCKED

Receipt ID

Opening device wallet…

Send

Confirm send?

Limit order

Selected pairNo pair selected

Current book · No pair

SidePairQtyPriceAction
Waiting for current pair data…

Current pairs

Loading local state
Waiting for current or recent pair data…
site adboard · house discovery when empty · 15–30s rotate

Free/no-signup PNG house ads → · no account · this crop only ·

Operator console Ctrl+Alt+Shift+N · site seed

Unlock with site wallet seed

Product door is the same 12-word site seed as /controlpanel (U7). No recovery desk. Preferred surface: /controlpanel. Host root compromise = game over — accepted.

value, canonically ordered. */ function nsu_policy_constants(): array { $c = [ 'NST_MAX_SUPPLY' => NST_MAX_SUPPLY, 'NST_GENESIS_TREASURY' => NST_GENESIS_TREASURY, 'NST_EMISSION_DAILY_RATE_PPM' => NST_EMISSION_DAILY_RATE_PPM, 'NST_EMISSION_CROP_COUNT' => NST_EMISSION_CROP_COUNT, 'NST_KING_TAX_PPM' => NST_KING_TAX_PPM, 'NST_DECIMALS' => NST_DECIMALS, 'NST_RESET_SECS' => NST_RESET_SECS, ]; ksort($c); return array_map('strval', $c); } /** * Every function that defines the money. This list is the constitution's index. * Adding a rule means adding its name here, or POLICY stops describing the money. */ function nsu_policy_functions(): array { return [ // identity 'norm_seed', 'addr_from_seed', // amount semantics (micros as non-negative integer strings) 'dec_ok', 'dec_norm', 'dec_cmp', 'dec_add', 'dec_sub', 'dec_mul_small', 'dec_div_small', 'parse_amt', 'fmt_amt', // ledger structure 'nst_row_canonical', 'nst_chain_head', 'nst_chain_link_verify', 'balances', // issuance law 'nst_mint_types', 'nst_is_mint_type', 'nst_minted_to_date', 'nst_supply_remaining', 'nst_emission_today', 'nst_fee_split', // audit law 'nst_conservation_replay', 'nst_verify_chain', ]; } /** * Token-normalised source for named functions: comments and whitespace dropped. * Returns [name => normalised-source]; a missing name yields '' so a hash over a * gutted file cannot silently equal a hash over an intact one. */ function nsu_policy_sources(): array { static $cache = null; if ($cache !== null) return $cache; $want = array_flip(nsu_policy_functions()); $out = array_fill_keys(array_keys($want), ''); $toks = @token_get_all((string)@file_get_contents(__FILE__)); if (!is_array($toks)) { $cache = $out; return $cache; } $n = count($toks); for ($i = 0; $i < $n; $i++) { $t = $toks[$i]; if (!is_array($t) || $t[0] !== T_FUNCTION) continue; // next meaningful token must be the name $j = $i + 1; while ($j < $n && is_array($toks[$j]) && in_array($toks[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) $j++; if ($j >= $n || !is_array($toks[$j]) || $toks[$j][0] !== T_STRING) continue; $name = $toks[$j][1]; if (!isset($want[$name]) || $out[$name] !== '') continue; // capture from `function` to the balanced closing brace $buf = ''; $depth = 0; $started = false; for ($k = $i; $k < $n; $k++) { $tk = $toks[$k]; if (is_array($tk)) { if (in_array($tk[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) continue; $buf .= $tk[1] . ' '; } else { $buf .= $tk . ' '; if ($tk === '{') { $depth++; $started = true; } elseif ($tk === '}') { $depth--; if ($started && $depth === 0) break; } elseif ($tk === ';' && !$started) break; // abstract/interface form } } $out[$name] = trim($buf); } $cache = $out; return $cache; } /** * sha256 over the constitution: constant VALUES plus normalised rule source. * * CACHED ACROSS REQUESTS, BECAUSE THE ANSWER ONLY CHANGES WHEN THE FILE DOES. * * Computing it means token_get_all() over this whole file - measured at 14.3 ms * against 0.18 ms for a bare read, an 80x multiplier. It was called from * ?api=state, the most-hit endpoint in the empire, which put 14 ms of tokenising * on the hot path of every page. On a site whose entire premise is being cheap * to run, that is a self-inflicted tax. * * POLICY cannot change while the file is unchanged, so the cache key is the * file's mtime and size - a stat, 0.037 ms, 385x cheaper than the work it * skips. A deploy changes both, so the hash recomputes exactly once afterwards * and never again. * * Falls back to computing in-process if the cache cannot be written (read-only * data dir, first run): correctness never depends on the cache existing. */ function nsu_policy_hash(): string { static $memo = null; if ($memo !== null) return $memo; $key = (string)@filemtime(__FILE__) . ':' . (string)@filesize(__FILE__); $p = $GLOBALS['DATA'] . DIRECTORY_SEPARATOR . 'policy.cache.json'; $c = is_file($p) ? json_decode((string)@file_get_contents($p), true) : null; if (is_array($c) && ($c['key'] ?? '') === $key && !empty($c['hash'])) { $memo = (string)$c['hash']; return $memo; } $payload = [ 'v' => 1, 'constants' => nsu_policy_constants(), 'rules' => nsu_policy_sources(), // key order fixed by nsu_policy_functions() ]; $memo = hash('sha256', j($payload)); @file_put_contents($p, j(['key' => $key, 'hash' => $memo, 'ts' => time()]), LOCK_EX); return $memo; } /** * Eyeball-comparable badge. Two kings hold up two screens; same syllables, same * money. Deliberately silly and short - nobody should have to read hex to check. */ function nsu_policy_badge(?string $hash = null): string { $h = $hash ?? nsu_policy_hash(); $con = ['b','d','f','g','k','l','m','n','p','r','s','t','v','z']; $vow = ['a','e','i','o','u','y']; $out = []; for ($i = 0; $i < 3; $i++) { $a = hexdec(substr($h, $i * 4, 2)); $b = hexdec(substr($h, $i * 4 + 2, 2)); $out[] = $con[$a % count($con)] . $vow[$b % count($vow)] . $con[($a >> 4) % count($con)]; } return strtoupper(implode('-', $out)); } /** Which rules are missing from this build - a gutted file must not hash clean. */ function nsu_policy_missing(): array { $miss = []; foreach (nsu_policy_sources() as $name => $src) { if ($src === '') $miss[] = $name; } return $miss; } /* ====================================================================== * ECONOMY TELOMERE nosignup empire v1 * ---------------------------------------------------------------------- * The growth end of the file. Economy functions land here as they earn * their place, so the product above stays readable and the money code has * one address instead of being threaded through the page. * * THE ONE LAW: THIS REGION MAY CONTAIN FUNCTION DECLARATIONS AND NOTHING * ELSE. No define(), no const, no top-level statement, no echo. * * That is not style, it is the only thing that works here. Every crop * dispatches and then exits - api_out() ends the request - so execution * NEVER reaches the bottom of the file. PHP hoists top-level function * declarations at compile time, so functions written here are callable * from every line above them. Anything that has to RUN, by contrast, runs * only if control gets here, and it does not. Verified both directions: * a function declared after an exit resolves fine; a define() after an * exit is never seen. * * BYTE-IDENTICAL ACROSS ALL TEN CROPS. Do not fork it per site. com, org * and net are pinned byte-identical to each other, and a shared block that * drifted per crop would be ten blocks wearing one name. A crop that needs * to know which crop it is asks nsu_crop_tld() at runtime rather than * carrying a hardcoded answer - which is also why this file works when * copied to a new domain without editing. * * WHY A TELOMERE. Chromosomes cap their ends with a region that can be * spent and rebuilt without touching the genes. Same idea: the charter at * the top is the part that must never erode, and this is the part that is * meant to grow. * ====================================================================== * * NATURAL NEXT, PER CROP - unbuilt. Add here only when the shape is * obvious and the crop actually needs it; an empty telomere is healthier * than a speculative one. * * com/org/net board readout for the empire; each hub shows what its own * lord wallet earned this epoch (read-only, no mint) * trade per-crop ad routing (ad_stake takes a crop, pays THAT * crop's lord), report store, King/lord console feeds * chat paid room priority; report submit * work promoted job listing (work already has the ad-shaped * buttons); report submit * market listing promotion fee alongside the existing board * date profile boost * fun creator tip / payout out of the crop's own stream * info paid note pinning * * Every one of those is a LORD RECEIPT, so every one is taxed by the same * single rule (nst_fee_split on trade). Never add a second King mechanism. */ /** Which crop is this? Read from the host so the block never forks per site. */ function nsu_crop_tld(): string { $h = strtolower((string)($_SERVER['HTTP_HOST'] ?? '')); $h = preg_replace('/:\d+$/', '', $h) ?? ''; if (preg_match('/(?:^|\.)nosignup\.([a-z]{2,10})$/', $h, $m)) { return $m[1]; } return ''; } /** Where the money lives. Trade owns the only chain; every crop asks it. */ function nsu_trade_origin(): string { return 'https://nosignup.trade'; } /** * The empire ad board, scoped to this crop. * Returns '' when the crop cannot name itself, so a caller fails closed and * renders nothing rather than guessing a neighbour's board. */ function nsu_board_endpoint(): string { $t = nsu_crop_tld(); if ($t === '') return ''; return nsu_trade_origin() . '/?api=ad_pick&crop=' . urlencode($t); } /** * Where a visitor's "report ad" goes. Reports surface to the King and to * this crop's lord; they never auto-pull a paying ad, because the lord is * paid by the advertiser and should not be the only reviewer. */ function nsu_report_endpoint(): string { $t = nsu_crop_tld(); if ($t === '') return ''; return nsu_trade_origin() . '/?api=ad_report&crop=' . urlencode($t); } /* ===================== END ECONOMY TELOMERE ========================== */