From 9f725d54525b1083a4ee0381b33de70b95faf8da Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Wed, 16 Sep 2026 13:21:53 +0530 Subject: [PATCH] Finish 0.3.0: prove Kraken scope, drop the futures claim, close three netting and terminal bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kraken publishes GetApiKeyInfo, gated on no permission of its own, so trade access is proven rather than reported as unknown and a key that can place orders is refused at connect time. Four surfaces said the opposite. /binance advertised USD-M futures that verifyScope makes unreachable — the connector's own comment said so, and README said the opposite of the page. Three defects found by running the code rather than reading it: - assetOn's bridge guard sat behind `name === scoped`, so it never ran for ten bridged tickers. A contract calling itself GNO on Linea took the bare ticker, netting into the real holding and drawing its price. - A Hyperliquid builder dex could take a chain's name, putting `optimism:USDT` on the book — the same shape a bridged token gets. - whenLeaving registered a handler per caller, each re-raising after its own cleanup. Re-raising ends the process, so only the first mode registered was ever handed back. Also: non-JSON venue bodies no longer read as a bug in tula, a corrupt credentials.json names the way out instead of being emptied by the read path, a refused CoinGecko id is asked again rather than cached for the TTL, and `npm update -g` cannot cross a 0.x minor so the instruction is `npm install`. Venue facts re-checked against each venue's own docs: Aave removed stable-rate debt in v3.2 and the Safety Module is now Umbrella; Hyperliquid publishes the 95% trigger for portfolio margin only; Coinbase's perps are INTX. Co-Authored-By: Claude Opus 5 (1M context) --- .githooks/allowed-secrets | 18 ++ .github/workflows/ci.yml | 24 +++ AGENTS.md | 50 ++++-- CHANGELOG.md | 17 +- README.md | 14 +- SECURITY.md | 10 +- package.json | 15 +- scripts/guard-test.sh | 21 ++- scripts/guard.sh | 36 +++- scripts/npm-pack.sh | 4 +- site/app/aave/page.tsx | 64 +++++++ site/app/binance/page.tsx | 61 +++++++ site/app/coinbase/page.tsx | 58 ++++++ site/app/exposure/page.tsx | 58 ++++++ site/app/globals.css | 10 ++ site/app/hyperliquid/page.tsx | 69 +++++++ site/app/icon.png/route.tsx | 37 ++++ site/app/install/page.tsx | 37 ++-- site/app/keys/page.tsx | 12 +- site/app/kraken/page.tsx | 60 +++++++ site/app/layout.tsx | 20 ++- site/app/liquidation-risk/page.tsx | 71 ++++++++ site/app/llms.txt/route.ts | 2 +- site/app/not-found.tsx | 11 +- site/app/page.tsx | 4 +- site/app/security/page.tsx | 16 +- site/app/sitemap.ts | 23 +-- site/components/Channels.tsx | 6 +- site/components/Copy.tsx | 8 +- site/components/Ext.tsx | 39 ++-- site/components/Footer.tsx | 132 ++++++++++---- site/components/Guide.tsx | 85 +++++++++ site/components/MobileNav.tsx | 103 +++++++++++ site/components/Nav.tsx | 27 +-- site/components/VersionPill.tsx | 22 +++ site/lib/site.ts | 68 ++++++- site/vercel.json | 2 +- src/cli/session.ts | 6 +- src/connectors/aave.test.ts | 22 ++- src/connectors/aave.ts | 10 +- src/connectors/binance.ts | 7 +- src/connectors/hyperliquid.test.ts | 33 +++- src/connectors/hyperliquid.ts | 26 ++- src/connectors/kraken.test.ts | 42 ++++- src/connectors/kraken.ts | 51 ++++-- src/connectors/stripe.ts | 4 +- src/connectors/symbols.test.ts | 83 ++++++++- src/connectors/symbols.ts | 53 +++++- src/connectors/types.ts | 2 +- src/connectors/wallet.test.ts | 29 ++- src/connectors/wallet.ts | 6 +- src/core/http.test.ts | 4 +- src/core/http.ts | 21 +++ src/core/paths.ts | 3 +- src/history/bip39.ts | 185 ------------------- src/history/bip39/chinese-simplified.ts | 54 ++++++ src/history/bip39/chinese-traditional.ts | 54 ++++++ src/history/bip39/czech.ts | 198 ++++++++++++++++++++ src/history/bip39/english.ts | 174 ++++++++++++++++++ src/history/bip39/french.ts | 219 +++++++++++++++++++++++ src/history/bip39/italian.ts | 214 ++++++++++++++++++++++ src/history/bip39/japanese.ts | 134 ++++++++++++++ src/history/bip39/korean.ts | 185 +++++++++++++++++++ src/history/bip39/portuguese.ts | 209 +++++++++++++++++++++ src/history/bip39/spanish.ts | 181 +++++++++++++++++++ src/history/history.test.ts | 53 ++++++ src/history/history.ts | 55 +++++- src/prices/coingecko.test.ts | 39 +++- src/prices/coingecko.ts | 16 +- src/secrets/store.test.ts | 20 +++ src/secrets/store.ts | 29 ++- src/site-claims.test.ts | 177 ++++++++++++++++-- src/site-example.test.ts | 28 ++- src/ui/app.tsx | 2 +- src/ui/mouse.test.ts | 16 +- src/ui/run.tsx | 19 +- src/ui/screen.test.ts | 67 +++++-- src/ui/terminal.test.ts | 52 +++++- src/ui/terminal.ts | 64 +++++-- src/update/apply.ts | 7 +- src/update/channel.ts | 6 +- src/update/check.test.ts | 46 ++--- src/update/check.ts | 31 ++-- src/update/state.test.ts | 9 +- src/update/state.ts | 18 +- tasks/distribution/06-self-update.md | 4 +- tasks/hardening/03-docs-site.md | 3 +- 87 files changed, 3726 insertions(+), 558 deletions(-) create mode 100644 site/app/aave/page.tsx create mode 100644 site/app/binance/page.tsx create mode 100644 site/app/coinbase/page.tsx create mode 100644 site/app/exposure/page.tsx create mode 100644 site/app/hyperliquid/page.tsx create mode 100644 site/app/icon.png/route.tsx create mode 100644 site/app/kraken/page.tsx create mode 100644 site/app/liquidation-risk/page.tsx create mode 100644 site/components/Guide.tsx create mode 100644 site/components/MobileNav.tsx create mode 100644 site/components/VersionPill.tsx delete mode 100644 src/history/bip39.ts create mode 100644 src/history/bip39/chinese-simplified.ts create mode 100644 src/history/bip39/chinese-traditional.ts create mode 100644 src/history/bip39/czech.ts create mode 100644 src/history/bip39/english.ts create mode 100644 src/history/bip39/french.ts create mode 100644 src/history/bip39/italian.ts create mode 100644 src/history/bip39/japanese.ts create mode 100644 src/history/bip39/korean.ts create mode 100644 src/history/bip39/portuguese.ts create mode 100644 src/history/bip39/spanish.ts diff --git a/.githooks/allowed-secrets b/.githooks/allowed-secrets index c22adfe..a6d0de8 100644 --- a/.githooks/allowed-secrets +++ b/.githooks/allowed-secrets @@ -254,3 +254,21 @@ c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8 # Arbitrum USDC 0xff970a61a04b1ca14834a43f5de4533ebddb5cc8 + +# Each chain's canonical wrap of its gas token, which src/connectors/symbols.ts +# nets with that gas token. Each is the reserve Aave's address book names, with +# code present and symbol() checked on-chain. +# Arbitrum One WETH +0x82af49447d8a07e3bd95bd0d56f35241523fbab1 +# Base and Optimism WETH, the OP-stack predeploy +0x4200000000000000000000000000000000000006 +# Scroll WETH, a predeploy +0x5300000000000000000000000000000000000004 +# Linea WETH +0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f +# Polygon WPOL, formerly WMATIC +0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270 +# Avalanche WAVAX +0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7 +# Gnosis WXDAI +0xe91d153e0b41518a2ce8dd3d7944fa863463a97d diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bb6a65..64c4a54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.2.16 @@ -35,6 +37,24 @@ jobs: - name: every release target still cross-compiles run: bash scripts/release-build.sh dist/release + # The release job runs `bun run check` on macOS, where the binary is signed, so + # the same command runs here: a failure only macOS shows has to fail the pull + # request, not the tag. + check-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.2.16 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - run: bun install --frozen-lockfile + - run: bun run check + # The site is the published origin of install.sh and of every claim a reader # sees before installing anything. Vercel builds it too, but a preview that # fails is a link nobody opens; this fails the pull request itself. @@ -42,6 +62,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.2.16 @@ -68,6 +90,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # guard.sh is greps; guard-test.sh is not. Whether venue text in a tool # result carries its mark is a data-flow property, so the probe for it # plants a field and runs the check that catches one. diff --git a/AGENTS.md b/AGENTS.md index e34140c..f3ca1b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ replaced by a literal. bun install bun run typecheck # tsc --noEmit bun test # unit tests -bun run check # typecheck, test, install path, guards, scan test — CI runs each, split across jobs +bun run check # typecheck, test, install path, guards, scan test — CI runs each, and all of it on macOS bun run build # -> dist/tula bun run dev # run from source ``` @@ -236,7 +236,17 @@ src/ command.ts # /update and /update install history/ history.ts # submitted lines kept across sessions; never a connect field, never a line that looks like a key - bip39.ts # BIP-39's English wordlist, so a pasted seed phrase is never kept + bip39/ # BIP-39's official wordlists, one file each, so a pasted seed phrase in any of them is never kept + english.ts + chinese-simplified.ts + chinese-traditional.ts + czech.ts + french.ts + italian.ts + japanese.ts + korean.ts + portuguese.ts + spanish.ts prefs/ prefs.ts # preferences.json — vim mode and whatever preference follows; unreadable means default agent/ @@ -411,8 +421,8 @@ Two rules, and they are the reason the architecture exists: - **The version check carries nothing about the caller.** No identifier, no version, no query string — a GET of a public release page, resolved off the redirect for the reason `install.sh` avoids the API. Its state lives in - `state.json`, never `credentials.json`: writing a timestamp must not mean - importing the module that reads venue keys. + `state.json`, never `credentials.json`: recording which release was announced + must not mean importing the module that reads venue keys. - **Signed quantities.** Debt and shorts are negative so netting is a plain sum. - **Ordering is presentation.** Connectors return what the venue gave them; the command layer sorts. A new connector must not change how the table reads. @@ -710,7 +720,7 @@ bun run build # -> site/out, static static host could not do. Vercel reads it from the project's root directory, which is `site`; point that at the repository root instead and every header here silently stops being sent. The CSP is the security page's egress claim - enforced: same origin, plus Google Analytics, and nothing else. `script-src` + enforced: same origin, plus Google Analytics and the Peerlist badge image, and nothing else. `script-src` carries `'unsafe-inline'` and cannot lose it — Next inlines the RSC flight payload into every page, `output: 'export'` leaves no middleware to mint a nonce, and the hashes change every build. HSTS is deliberately without @@ -738,6 +748,18 @@ bun run build # -> site/out, static served for every path on the domain there is nothing at. It is not in `NAV`, which is the list of routes the sitemap and `llms.txt` publish, and a 404 in either is a 404 arrived at from a search result. +- **The guides** — `app/liquidation-risk`, `app/exposure`, `app/hyperliquid`, + `app/aave`, `app/kraken`, `app/binance` and `app/coinbase`, built on + `components/Guide.tsx` — are pages written for a search, linked from the + footer's Guides column, beside Keys, and nowhere more prominent. A hidden link would be spam + under Google's policy, and a page nothing links to ranks weakly. +- `components/VersionPill.tsx` draws the release the site describes, in the + header and in the mobile menu. It reads `VERSION` from `lib/site.ts` and + derives the pre-release marker from the hyphen, the same rule `src/version.ts` + applies to the binary — never a second hand-set flag. +- `app/icon.png/route.tsx` and `app/apple-icon.png/route.tsx` render the favicon + and the touch icon from the same mark `app/icon.svg` draws, because a PNG is + what a browser tab and an iOS home screen ask for. - `agentRules: false` in `next.config.ts`: `next dev` otherwise writes a second AGENTS.md and CLAUDE.md under `site/`, and this file is the only one. - **The changelog and the roadmap are not on the site.** `CHANGELOG.md`, @@ -761,11 +783,11 @@ bun run build # -> site/out, static desktop gets between them — a quarter of its screen with nothing in it. `--gutter` clamps for the same reason. A section gap belongs in these tokens, not in a raw step count. -- **The header and footer bars centre below the `phone` breakpoint.** A wordmark - held left against a nav held right is a shape that needs a row wide enough for - both ends; wrapped, `ml-auto` leaves each half on the edge it was pushed to and - they read as two halves that missed each other. `globals.css` says why the - breakpoint sits where it does. +- **Below the `phone` breakpoint the header's links fold behind a menu + button.** Wrapped, they took a pinned header to two or three rows of a phone + screen. `MobileNav.tsx` is a disclosure, not `role="menu"`: WAI-ARIA reserves + that role for application menus that take arrow keys. `globals.css` says why + the breakpoint sits where it does. - **One command per copyable block on the install page.** Every block carries a copy button, so two alternatives sharing one is a paste that installs tula twice — which is why Homebrew and npm are separate blocks, and why update, go @@ -781,7 +803,7 @@ bun run build # -> site/out, static sideways-scrolling table puts it off a phone with nothing to say it is there. Horizontal scroll is right for the terminal frames, which are a picture of a fixed-width grid, and wrong for anything a reader has to read. -- **Seven client component files, and each one earns it by needing something +- **Eight client component files, and each one earns it by needing something CSS cannot read.** `Session.tsx` draws the front page's frame and works `/`, ctrl+s and ctrl+o on a loop because a transcript cannot show a keystroke — every state it passes through is one the binary draws, in the binary's own @@ -809,6 +831,8 @@ bun run build # -> site/out, static block, and its live region sits outside the button, because a button's children are presentational and a region nested in one is not reliably announced. + `MobileNav.tsx` holds the header's links on a phone; it closes on Escape, a + tap outside or a route change, and Escape hands focus back to its button. `Nav.tsx` and `Channels.tsx` measure where the active item sits so one underline can travel between them; `lib/marker.ts` is that measurement, held in one place because two copies of it drift apart by a pixel and read as a @@ -846,7 +870,7 @@ bun run build # -> site/out, static one is summarised by, and the preview card's dimensions and alt text. The nav routes feed the footer, the sitemap and `llms.txt` from one place, so a new page cannot ship unindexed or unsummarised; the header shows only the ones - marked `inHeader`. `src/site-claims.test.ts` reads + marked `inHeader`, and the 404 only the `site` group. `src/site-claims.test.ts` reads this file rather than `layout.tsx` for the trading caveat — the description is written here and rendered there. - **`metadataBase` is the deployed URL.** Next resolves every canonical, @@ -860,7 +884,7 @@ bun run build # -> site/out, static naming the image by hand in `OG_IMAGE` rather than having Next infer it. - **Every metadata route needs `export const dynamic = 'force-static'`.** Under `output: export` the build refuses to collect a route it cannot prove is - static, and a `new Date()` in the sitemap is enough to make it doubt. + static, and a `new Date()` in one is enough to make it doubt. - **`robots.txt` and `.well-known/` are served from the origin root**, which the apex domain owns, so both are read rather than kept as a written record. Discovery still does not lean on `robots.txt`: `llms.txt` is linked from the diff --git a/CHANGELOG.md b/CHANGELOG.md index c2258ad..0b27725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ## [Unreleased] -## [0.3.0] - 2026-09-15 +## [0.3.0] - 2026-09-16 ### Added @@ -29,12 +29,15 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message - **What Hyperliquid is holding of a balance, and why.** Each hold is reconciled against the account's resting orders and the margin drawn on it. What those account for is named as an order or as margin; what they do not is shown as unknown, never as an order to cancel. - **Staked HYPE, vault deposits and sub-accounts.** Staked HYPE and vault equity are counted, and a vault equity is never netted with the coin, because it is a claim on a pool. Each sub-account is read as an account of its own. The borrow/lend book's health factor is stated but not ranked in `breaks`: Hyperliquid says one under 100% does not by itself mean liquidation risk. +- **A Kraken key is now proven on both powers, not just one.** Kraken publishes `GetApiKeyInfo`, which reports a key's whole permission list and is itself gated on no permission at all, so a key that can create, modify, cancel or close orders is refused at connect time instead of being stored with its trade access reported as unknown. A key the endpoint does not answer for still falls back to the withdrawal probe, and still says unknown rather than safe. + ### Changed - **The command palette moved from ctrl+k to ctrl+s.** ctrl+k deletes to the end of the line, as it does in every shell and every terminal tool with a line editor, and in none of them does it open a palette. - **The headline total is `Equity`, not `Net notional`.** A perp now adds the profit or loss its venue states, never its notional, so the same short moves the total by the same amount whichever venue holds it — before, it moved it by the account's equity at Hyperliquid, by cash plus notional at Coinbase, and by notional alone on the front page. It is the figure Hyperliquid calls Account Equity and Bybit, OKX and Deribit call equity. A derivatives position whose venue states no equity is named beside the total instead of being guessed at. The model is handed the same figure, as `equity_usd`. -- **A bridge's token is its own asset, named for its chain.** `polygon:USDC.E`, a bridge's USDT, DAI or WBTC, and wstETH off Ethereum net with neither the issuer's token nor another bridge's, so one bridge losing its peg is never averaged into the rest. Circle's USDC and Tether's USDT still net across every chain their issuer mints on, and USDT0 is one asset wherever it is. Each is priced by its own CoinGecko coin, never at the price of what it is named after; under a price source that cannot tell them apart it is unpriced and named under the table. A held token's balance is scaled by its contract's own decimals, whatever its token list says. +- **A bridge's token is its own asset, named for its chain.** `polygon:USDC.E`, a bridge's USDT, DAI or WBTC, and wstETH off Ethereum net with neither the issuer's token nor another bridge's, so one bridge losing its peg is never averaged into the rest. Circle's USDC and Tether's USDT still net across every chain their issuer mints on, and USDT0 is one asset wherever it is. Each is priced by its own CoinGecko coin, never at the price of what it is named after; under a price source that cannot tell them apart it is unpriced and named under the table. A contract that only calls itself by one of these names is kept apart and unpriced. A held token's balance is scaled by its contract's own decimals, whatever its token list says. - **A venue that answers in part says so, and every figure is from what did load.** When a Hyperliquid dex, staking, vaults, the borrow/lend book or a sub-account does not load, one line names it and what it leaves out, and `INCOMPLETE` counts the venue as answered in part rather than failed. A unified account's ratio is then computed over the dexes that loaded, printed as `at least` that figure, and still ranks the account; `shock` withholds it with the reason. +- **The shell checks for a new release each time it opens**, not once a day, so a release is heard of in the first session after it ships. It is still one request to GitHub's public releases page carrying nothing about you, a version is still mentioned once, and `TULA_NO_UPDATE_CHECK=1` still turns it off. - **`/shock` refuses an asset the book does not hold**, and names the assets it does. - **A one-shot command prints its data on stdout and what it says about that data on stderr.** Tables, totals and lists go to stdout; `INCOMPLETE`, `REMOVED` and `ALTERED`, a price source that did not answer, assets left out of a total, and what a ranking could not see go to stderr. A script redirecting `tula exposure` now gets only the table, and exit codes are unchanged. A one-shot command that did not produce what was asked for — an unknown or mistyped command, a usage error, a shell-only command, a venue that is not connected — prints its message on stderr and nothing on stdout; `--version`, `--help` and `about` still print on stdout. In the shell, `shock` lists the assets it could not price after the scenario rather than under its totals. Connecting an address-only venue says "Checking the address…" rather than "Verifying key scope…", since there is no key to check. @@ -46,9 +49,17 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message - **Aave's Arbitrum market counted Circle's USDC and the bridged USDC as one asset.** Both contracts call themselves `USDC`, so the two reserves shared a row; each is now its own. Aave's `USD₮0` also nets with a wallet's `USDT0`, where before it was a separate row. - **No single answer tula reads can fill memory.** A response past 16 MB fails by name, where an endpoint streaming without end was read until it stopped. - **A malformed token list no longer reads as an empty wallet.** A list that is not a token list fails each chain reading it, by name, and an entry with a malformed address, symbol or decimals is skipped. -- **A one-shot command speaks command-line.** `tula help` lists the commands that only work inside the shell apart, instead of as runnable; a hint says `tula coingecko use`, not `/coingecko use`; and `tula "shock ETH -20"` in quotes runs rather than printing its usage. +- **A one-shot command's help and hints are written for the command line.** `tula help` lists the commands that only work inside the shell apart, instead of as runnable; a hint says `tula coingecko use`, not `/coingecko use`; and `tula "shock ETH -20"` in quotes runs rather than printing its usage. - **A refused key is told how to make a read-only one in the venue's own terms**: Kraken's Query Funds, Binance's Enable Reading, Coinbase's View, Stripe's restricted key. - **A token list that fails is one line naming its chains**, not one per chain, and `/venues` lists every failure under the table instead of the first one inside it. +- **Nothing typed or pasted can put a terminal control sequence on the line.** Every control character but a line break is dropped, so a copied escape sequence cannot reach the terminal — one could write to the clipboard. +- **A token calling itself by a bridged asset's ticker no longer nets with the real holding.** On a chain that has a known bridge of a ticker — Linea's GNO, LINK and UNI, Base's USDbC, and seven more — any other contract answering to that name is now kept apart under its own contract and left unpriced. It used to take the bare ticker, so it netted into the real asset's row and drew that asset's price; the rule was only ever applied to tickers that also wrap or are issued. +- **A Hyperliquid builder dex can no longer take a chain's name.** A dex names itself, and its name becomes the scope on every market it lists — so one called `optimism` would have put `optimism:USDT` on the book, netting into the bridged row and pricing as it. A dex named after a chain in the build is reported as not read, and a spot token cannot spell the separator at all. +- **Every terminal mode is handed back on the way out, not just the first.** Closing the window or a `kill` ran one undo and then ended the process, so raw mode and mouse reporting survived where the keyboard protocol did not — leaving a shell with no echo, or writing escape codes at the prompt when the pointer moved. +- **A venue that answers with something other than JSON is named.** A proxy or a captive portal answering with an HTML page made Kraken and Stripe fail as "This is a bug in tula"; each now says which venue did not answer and what to try. +- **A `credentials.json` that cannot be read as JSON says so, and is left alone.** A truncated or hand-edited file reported itself as a bug in tula; it now names the file and the move to make. A file holding a list or a single value is refused outright rather than quietly written back as empty. +- **Homebrew and npm users are told to reinstall, not update.** `npm update -g` will not cross a 0.x minor, so the one instruction meant to move somebody onto a new release left them on the old one. +- **`state.json` is never written through a link, or into a folder other users can write to.** - **ctrl+c on a line recalled from history starts history over**, so the next ↑ is your newest line again. - **A printed credentials path starts at `~`**, so pasting `tula about` into a bug report does not carry your username. diff --git a/README.md b/README.md index 26c2178..6ca0db3 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,11 @@ proves that check still catches one. than dressed up. - **Exchange API keys must be query-only.** Scope is verified against the venue at connect time; a key that can withdraw is refused, not warned about. -- **Where a venue cannot prove scope, we say so.** Kraken proves a key cannot - withdraw — the endpoint gated on that permission reads without moving - anything, so a refusal is the proof. Nothing proves it cannot *trade*: every - trade-gated endpoint places or mutates an order. Trade is the permission tula - reports as *unknown*, rather than implying a check that did not happen. +- **Where a venue cannot prove scope, we say so.** Kraken reports a key's whole + permission list from an endpoint gated on none of them, so both powers are + proven and a key that can trade is refused. Stripe publishes no such check, and + tula reports its scope as *unknown* rather than implying a check that did not + happen. - **A venue with no read-only key is not a venue tula offers.** Unproven is one thing; a credential every one of whose forms can move money is another, and no wording on a connect screen makes it safe to store. Circle Mint was dropped for @@ -115,7 +115,7 @@ proves that check still catches one. Network egress is the venues you connect, the price source you chose, a public node on each chain read — Ethereum, Arbitrum One, Base, Polygon, Optimism, Avalanche, Gnosis, Scroll and Linea — and a token list for -the on-chain venues, GitHub once a day from the interactive shell to see whether +the on-chain venues, GitHub each time the interactive shell opens to see whether there is a newer release — and, only when you ask a question in plain English, Anthropic, which receives the computed figures and never a credential. Drive tula with commands and it never talks to a model at all. @@ -204,7 +204,7 @@ useful thing you can send. | What tula never asked for | working; `/venues` names every area a connector declares it does not read, with what each may hide, and every one of them is scheduled work in [ROADMAP.md](./ROADMAP.md) | | Interactive shell — slash commands and arguments that complete, ctrl+s to search them, ctrl+r through saved history, Readline keys, questions over several lines (ctrl+j, or shift+Enter where the terminal sends it), opt-in vim editing, ctrl+o for long output, ? for every key, type and queue the next line while one runs with Esc to stop a question, plain English | working; both command lists take the mouse as well as the keyboard | | Prices — CoinGecko, CoinPaprika, CoinMarketCap, CryptoCompare | working; one active at a time, `/ use` switches | -| Staying current — the shell checks once a day and says so in a line; `/update` checks there and then | working; nothing is installed until you type `/update install` | +| Staying current — the shell checks each time it opens and says so in a line; `/update` checks there and then | working; nothing is installed until you type `/update install` | | Kraken's account margin level | planned — Kraken liquidates on an account-wide level and no position carries that figure, so those rows rank `unknown` until it is read | | Binance futures | not while tula is read-only — Binance's futures permission grants trading, and a key holding it is refused | | Solana | planned — a different RPC and account model, so a connector of its own rather than a registry entry ([`breadth/12`](./tasks/breadth/12-chain-reach.md)) | diff --git a/SECURITY.md b/SECURITY.md index 016f021..6dbc34b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -266,20 +266,20 @@ Everything tula contacts, and nothing else: nothing to Anthropic; if you never ask a question, tula never talks to it. - **GitHub — `https://github.com` — to see whether there is a newer release.** The check that runs on - its own does so once a day at most and only in the interactive shell; + its own does so each time the interactive shell opens, and nowhere else; `TULA_NO_UPDATE_CHECK=1` stops it. Asking directly — `/update`, or `tula update` — checks there and then, because you asked. Either way it is a GET of the public `/releases/latest` page, carrying nothing about you — not - your version, not an identifier, no query string — so what GitHub sees is what - it sees from anyone opening that page. Nothing is ever installed by a check: + your version, not an identifier, no query string — so GitHub sees your IP + address and when a shell opened, as it would from anyone opening that page. Nothing is ever installed by a check: it prints a line, and `/update install` is a separate thing you type. Typing it fetches the release archive and `checksums.txt` from that repository, following GitHub's redirect to the storage host it serves assets from. Those are the only other requests, and only when you ask for them. No telemetry and no crash reporting. The update check is the only request the -binary makes that is not about your positions, and it is the only one that -reports nothing. +binary makes that is not about your positions, and it carries nothing about you +beyond what any page request does. ## Verifying a release diff --git a/package.json b/package.json index 1f2da85..c3ddd22 100644 --- a/package.json +++ b/package.json @@ -41,16 +41,23 @@ }, "homepage": "https://usetu.la", "keywords": [ - "trading", "crypto", "defi", + "trading", "portfolio", "risk", + "liquidation", + "health-factor", + "hyperliquid", + "aave", + "kraken", + "binance", + "coinbase", + "exposure", "tui", "cli", - "kraken", - "hyperliquid", - "aave" + "terminal", + "non-custodial" ], "patchedDependencies": { "ink@7.1.1": "patches/ink@7.1.1.patch" diff --git a/scripts/guard-test.sh b/scripts/guard-test.sh index 1bc535b..f59b515 100755 --- a/scripts/guard-test.sh +++ b/scripts/guard-test.sh @@ -19,6 +19,9 @@ SECRETS_PROBE=src/secrets/guard-probe.ts # Inside the history writer's own directory, for the reason the store's probe # sits inside the store's. HISTORY_PROBE=src/history/guard-probe.ts +# Inside the wordlist folder, which two rules skip: the probe has to be where +# code would hide from them. +BIP39_PROBE=src/history/bip39/guard-probe.ts # The boundary check's subject is the directory itself, so one probe below moves # it. Restored on an interrupt as well: leaving src/agent under another name is # a broken checkout, not a failed test. @@ -41,7 +44,7 @@ LAYOUT_GONE=src/connectors/registry.ts LAYOUT_SAVED=$(mktemp) cp "$LAYOUT_GONE" "$LAYOUT_SAVED" restore() { - rm -f "$PROBE" "$AGENT_PROBE" "$SECRETS_PROBE" "$HISTORY_PROBE" + rm -f "$PROBE" "$AGENT_PROBE" "$SECRETS_PROBE" "$HISTORY_PROBE" "$BIP39_PROBE" [ -d "$RENAMED" ] && mv "$RENAMED" src/agent cp "$CHANGELOG_SAVED" CHANGELOG.md cp "$TOOLS_SAVED" "$TOOLS" @@ -105,6 +108,15 @@ expect_credential() { ORDERS="an order, withdrawal or transfer endpoint is referenced in src/" SIGNING="a transaction-signing RPC is referenced in src/" KEYS="key material is handled outside src/connectors/coinbase.ts" +WORDLIST="src/history/bip39 holds something other than a wordlist: $BIP39_PROBE" + +# A valid first line, so each probe is refused for what follows it rather than +# for a missing comment. +expect_wordlist() { + printf '/** probe */\n%s\n' "$1" > "$BIP39_PROBE" + reports "$WORDLIST" "$1" + rm -f "$BIP39_PROBE" +} echo "guard-test: order and withdrawal endpoints" expect "$ORDERS" "const p = '/0/private/AddOrder'" @@ -129,6 +141,13 @@ expect "$KEYS" "const seedPhrase = ''" expect "$KEYS" "const m = 'mnemonic'" expect "$KEYS" "import { createPrivateKey } from 'node:crypto'" +echo "guard-test: code inside the wordlists the rules above skip" +expect_wordlist "export const seedPhrase = ''" +expect_wordlist "import { load } from '../../secrets/store.js'" +expect_wordlist 'export const WORDS = `abandon ${process.env.KEY}`' +expect_wordlist 'export const WORDS = `abandon` +export const mnemonic = 1' + # SECURITY.md states this one as an architectural guarantee rather than a habit, # and every probe here got past the two greps that used to stand for it. echo "guard-test: the agent layer reaching a credential or a venue" diff --git a/scripts/guard.sh b/scripts/guard.sh index 8e35c34..50164a3 100755 --- a/scripts/guard.sh +++ b/scripts/guard.sh @@ -31,12 +31,34 @@ fi # CDP credential *is* an asymmetric private key. Confining it is what lets the # site say what tula does with a key rather than pretending it never sees one. # Everywhere else, a public address or an HMAC secret and nothing more. +# bip39/ is word data, and Italian's list has "mnemonico" in it. if grep -rlE "(createPrivateKey|createSign|BEGIN [A-Z ]*PRIVATE KEY|privateKey|private_key|PRIVATE_KEY|seedPhrase|seed_phrase|SEED_PHRASE|mnemonic)" \ - src --include='*.ts' --include='*.tsx' --exclude='*.test.ts' | + src --include='*.ts' --include='*.tsx' --exclude='*.test.ts' --exclude-dir='bip39' | grep -v '^src/connectors/coinbase.ts$'; then report "key material is handled outside src/connectors/coinbase.ts" fi +# bip39/ is left out of the rule above and the wording rule below because it is +# word data, so it has to stay word data: a doc comment, then one exported string +# of words and nothing after it. A quote, a brace or a paren is code. +for f in src/history/bip39/*.ts; do + [ -e "$f" ] || continue + awk ' + NR == 1 { if ($0 !~ /^\/\*\* .* \*\/$/) bad = 1; next } + { + line = $0 + if (NR == 2) { + if (line !~ /^export const WORDS = `/) bad = 1 + sub(/^export const WORDS = `/, "", line) + } + if (line ~ /[(){};=$\\\047"]/) bad = 1 + ticks += gsub(/`/, "`", line) + last = $0 + } + END { if (bad || NR < 2 || ticks != 1 || last !~ /`$/) exit 1 } + ' "$f" || report "src/history/bip39 holds something other than a wordlist: $f" +done + # The agent layer sees computed views only: no credential, no venue client. # # A walk over the transitive imports, not a grep of what src/agent itself @@ -204,8 +226,8 @@ fi # between `TULA` and `DEMO` and matched nothing — and `TULA_DEMO` is exactly how # the demo fixture this check stands as the evidence against was spelled. SKETCH="(^|[^A-Za-z])(demo|dummy|fake|toy|playground|just a test|for now)([^A-Za-z]|$)" -# bip39.ts is BIP-39's wordlist, which has "toy" in it. -if grep -rniE "$SKETCH" src --include='*.ts' --include='*.tsx' --exclude='*.test.ts' --exclude='bip39.ts'; then +# bip39/ holds BIP-39's wordlists, where "toy" is a word. +if grep -rniE "$SKETCH" src --include='*.ts' --include='*.tsx' --exclude='*.test.ts' --exclude-dir='bip39'; then report "language that reads as a toy project is in shipped source" fi # The site's own source only: node_modules and .next are dependencies and build @@ -321,9 +343,11 @@ grep -q "\"homepage\": \"$site_url\"" package.json || report "package.json homepage does not match SITE_URL in src/version.ts ($site_url)" # A raw internal anchor leaves the app: a full document load, so the reader -# waits for the whole site again and lands wherever the browser puts them. -if grep -rn ' instead" +# waits for the whole site again and lands wherever the browser puts them. Every +# anchor, not just the internal ones — off-site links owe its rel, and the +# one anchor that was neither was the one this could not see. +if grep -rn '/dev/null | grep -v 'components/Ext.tsx'; then + report "a raw anchor; use for an internal route and for an off-site one" fi # components/Link is where every internal route turns Next's own scroll reset diff --git a/scripts/npm-pack.sh b/scripts/npm-pack.sh index 23cc4c9..9bbb94d 100755 --- a/scripts/npm-pack.sh +++ b/scripts/npm-pack.sh @@ -16,6 +16,8 @@ VERSION=$(grep -m1 'APP_VERSION' src/version.ts | sed "s/.*'\([^']*\)'.*/\1/") REPO_URL=$(grep -m1 'REPO_URL' src/version.ts | sed "s/.*'\([^']*\)'.*/\1/") DESCRIPTION=$(grep -m1 'APP_DESCRIPTION' src/version.ts | sed "s/.*'\([^']*\)'.*/\1/") SITE_URL=$(grep -m1 'SITE_URL' src/version.ts | sed "s/.*'\([^']*\)'.*/\1/") +# One list: the root manifest's, so the published package is found by the same words. +KEYWORDS=$(node -p "JSON.stringify(require('./package.json').keywords)") # npm's own names for what our artifacts call darwin-arm64 and so on. The # postinstall resolves `${process.platform}-${process.arch}`, so these have to @@ -88,7 +90,7 @@ $OPTIONAL }, "repository": { "type": "git", "url": "git+$REPO_URL.git" }, "homepage": "$SITE_URL", - "keywords": ["trading", "crypto", "defi", "portfolio", "risk", "tui", "cli"] + "keywords": $KEYWORDS } JSON diff --git a/site/app/aave/page.tsx b/site/app/aave/page.tsx new file mode 100644 index 0000000..45f2ace --- /dev/null +++ b/site/app/aave/page.tsx @@ -0,0 +1,64 @@ +import { Code } from '@/components/Code' +import { Guide, guideMetadata, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/aave' +const TITLE = 'Aave health factor and distance to liquidation' +const SUMMARY = + 'How far Aave collateral can fall before the health factor reaches 1, across twelve Aave v3 markets on nine chains, from one public address.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +
+

+ At a health factor of HF, the collateral can fall 1 − 1/HF before the market is + liquidatable. At 2 that is 50%. At 1.37, about 27%. +

+
+ +
+

+ Collateral, debt and each market’s health factor, across twelve markets on Ethereum, + Arbitrum One, Base, Polygon, Optimism, Avalanche, Gnosis, Scroll and Linea, from one + address. +

+

+ Each market’s own liquidation thresholds, and its eMode category’s where the account is in + one. All of it counts in net exposure. +

+
+ +
+

+ shock reprices the book and shows each new health factor. See{' '} + liquidation risk across venues. +

+ {'tula shock ETH -20'} +
+ +
+

+ Aave V4, Umbrella and the legacy Safety Module, isolation mode, and other chains. +

+
+ +
+

+ A public address is enough. Install tula, then: +

+ {'tula connect aave\ntula breaks'} +
+
+ ) +} diff --git a/site/app/binance/page.tsx b/site/app/binance/page.tsx new file mode 100644 index 0000000..5aea634 --- /dev/null +++ b/site/app/binance/page.tsx @@ -0,0 +1,61 @@ +import { Guide, guideMetadata, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/binance' +const TITLE = 'Binance read-only API key for portfolio tracking' +const SUMMARY = + 'Which Binance API restriction to turn on so a tool can read balances and positions but never trade or withdraw, and what tula refuses.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +
+

Create a key with only Enable Reading turned on.

+
+ +
+

+ Binance reports what a key can do. A key that can trade, withdraw or move your funds is + refused. +

+
+ +
+

+ Spot balances, free and locked stated apart, and cross and isolated margin with the + liquidation price each carries. +

+
+ +
+

+ Futures, because Binance’s futures permission grants trading and a key holding it is + refused. Also Portfolio Margin, the margin level a cross-margin account is liquidated at, + Earn and staking, and sub-accounts. See{' '} + why that matters for ranking. +

+
+ +
+

+ Install tula, then connect with the key: +

+ {'tula connect binance'} +

+ The same on Kraken and Coinbase.{' '} + How keys are kept. +

+
+
+ ) +} diff --git a/site/app/coinbase/page.tsx b/site/app/coinbase/page.tsx new file mode 100644 index 0000000..eceb815 --- /dev/null +++ b/site/app/coinbase/page.tsx @@ -0,0 +1,58 @@ +import { Guide, guideMetadata, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/coinbase' +const TITLE = 'Coinbase read-only API key (CDP View permission)' +const SUMMARY = + 'Which Coinbase CDP key permission lets a tool read Advanced accounts and perps without trading or moving funds, and what tula refuses.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +
+

Create a CDP key with only the View permission.

+
+ +
+

+ Coinbase reports which permissions a key has. A key that can trade or transfer is refused. +

+
+ +
+

+ Every account the key can list, and INTX perpetual futures with the liquidation price + Coinbase publishes. +

+
+ +
+

+ Portfolios other than the key’s own, and CFTC-regulated futures — the US perpetual-style + contracts on Coinbase Derivatives, which are not the INTX ones above. +

+
+ +
+

+ Install tula, then connect with the key: +

+ {'tula connect coinbase'} +

+ The same on Kraken and Binance.{' '} + How keys are kept. +

+
+
+ ) +} diff --git a/site/app/exposure/page.tsx b/site/app/exposure/page.tsx new file mode 100644 index 0000000..8f0ab69 --- /dev/null +++ b/site/app/exposure/page.tsx @@ -0,0 +1,58 @@ +import { Code } from '@/components/Code' +import { Guide, guideMetadata, Output, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/exposure' +const TITLE = 'Net crypto exposure across exchanges and wallets' +const SUMMARY = + 'Spot, perps and collateral netted into one figure per asset across every venue, and an Equity total that never adds a perp’s notional.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +
+

+ exposure adds every holding and position in an asset, across venues. +

+ + {`ASSET NET NOTIONAL VENUES AS OF +───── ─────── ────────── ─────────────────────── ───────────────── +ETH 6.64 $16,268.00 kraken hyperliquid aave 09:14:02 (4s ago)`} + +
+ +
+

+ The total counts a perp at the profit or loss its venue states, never its notional. + Hyperliquid calls it Account Equity; Bybit, OKX and Deribit call it equity. +

+
+ +
+

+ A bridge’s token, such as USDC.e on Polygon, is its own asset. It never nets into Circle’s + USDC. +

+
+ +
+

+ See liquidation risk,{' '} + Hyperliquid and Aave.{' '} + Install tula, then: +

+ {'tula exposure'} +
+
+ ) +} diff --git a/site/app/globals.css b/site/app/globals.css index 949d51f..86a7117 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -151,6 +151,16 @@ a:hover { color: var(--color-notice); } + /* Inside a sentence a link is underlined too: colour alone is the one cue a + reader who cannot tell accent from body text never gets. */ + p a { + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-decoration-color: var(--color-accent-dim); + } + p a:hover { + text-decoration-color: currentColor; + } } @layer components { diff --git a/site/app/hyperliquid/page.tsx b/site/app/hyperliquid/page.tsx new file mode 100644 index 0000000..95853f6 --- /dev/null +++ b/site/app/hyperliquid/page.tsx @@ -0,0 +1,69 @@ +import { Code } from '@/components/Code' +import { Guide, guideMetadata, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/hyperliquid' +const TITLE = 'Hyperliquid liquidation price and margin ratio' +const SUMMARY = + 'How Hyperliquid liquidates a position or a whole account, and how tula reads every account mode, builder dex and sub-account.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +
+

+ Standard. Each position liquidates at + its own price. +

+

+ Unified account. The whole account is + at risk once its Unified Account Ratio passes 95%, in Hyperliquid’s own words. +

+

+ Portfolio margin. Hyperliquid + documents the account as liquidatable once its Portfolio Margin Ratio passes 95%. +

+
+ +
+

+ The account mode and its balances, perps on every builder-deployed dex, staked HYPE and + every sub-account, all counted in net exposure. Vault equity + counts in the total but not in net exposure: it is a claim on a pool, not the coin. +

+

+ Under unified or portfolio margin, breaks ranks the whole account on its + ratio. See liquidation risk across venues. +

+
+ +
+

+ The unified ratio shows as “at least” the figure over the dexes that did load, and the one + that did not is named. +

+
+ +
+

Balances on HyperEVM, Hyperliquid’s own EVM chain.

+
+ +
+

+ A public address is enough. Install tula, then: +

+ {'tula connect hyperliquid\ntula breaks'} +
+
+ ) +} diff --git a/site/app/icon.png/route.tsx b/site/app/icon.png/route.tsx new file mode 100644 index 0000000..a20a5c1 --- /dev/null +++ b/site/app/icon.png/route.tsx @@ -0,0 +1,37 @@ +import { ImageResponse } from 'next/og' +import { ICON } from '@/lib/site' + +// Static export: rendered once at build time, as `app/og.png` is. +export const dynamic = 'force-static' + +/** + * The favicon a search result shows beside the site name. Google lists the + * formats it accepts, and SVG is not one of them, so `app/icon.svg` alone can + * leave the result with a generic globe. A multiple of 48px, as it asks. + * + * A route with the extension in its name, for the reason `apple-icon.png` is + * one. On the page's own ground, because a result page is white and the gold + * mark alone is faint on it. + */ +export function GET() { + return new ImageResponse( +
+ +
, + { width: ICON.size, height: ICON.size }, + ) +} diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx index 2cc8c91..31d3de8 100644 --- a/site/app/install/page.tsx +++ b/site/app/install/page.tsx @@ -16,7 +16,13 @@ export const metadata: Metadata = { title: TITLE, description: SUMMARY, alternates: { canonical: '/install' }, - openGraph: { ...OG, type: 'website', url: '/install', title: TITLE, description: SUMMARY }, + openGraph: { + ...OG, + type: 'website', + url: '/install', + title: `${TITLE} · ${NAME}`, + description: SUMMARY, + }, twitter: { ...TWITTER, title: `${TITLE} · ${NAME}`, description: SUMMARY }, } @@ -31,9 +37,9 @@ const CHECKS = [ /** * "Not found" is one message with three different causes, and the section sits - * below the tabs where a reader of any channel lands on it. It used to answer - * for the install script alone — which is not the channel that leaves a binary - * unreachable on purpose. Homebrew is: a pinned formula is `keg_only`. + * below the tabs where a reader of any channel lands on it. Answering for the + * install script alone misses the channel that leaves a binary unreachable on + * purpose: Homebrew, where a pinned formula is `keg_only`. */ const PATH_FIXES: [string, ReactNode][] = [ [ @@ -120,8 +126,8 @@ const CHANNELS: Channel[] = [ the one in the repo.

@@ -183,8 +189,9 @@ const CHANNELS: Channel[] = [ tula gets stable releases; tula-latest gets every release.

@@ -234,8 +241,8 @@ const CHANNELS: Channel[] = [

Node is needed to install it, not to run it.

@@ -246,7 +253,7 @@ const CHANNELS: Channel[] = [ - {'npm update -g @hsnice16/tula'} + {'npm install -g @hsnice16/tula'} @@ -265,7 +272,7 @@ export default function Page() { return (
-

+

Install

@@ -318,13 +325,13 @@ export default function Page() { key={system} className="block border-b border-rule py-3 sm:table-row sm:border-b-0 sm:py-0" > - + {system} - + {works} - + {note} diff --git a/site/app/keys/page.tsx b/site/app/keys/page.tsx index 6ceda9b..376f9a9 100644 --- a/site/app/keys/page.tsx +++ b/site/app/keys/page.tsx @@ -12,7 +12,13 @@ export const metadata: Metadata = { title: TITLE, description: SUMMARY, alternates: { canonical: '/keys' }, - openGraph: { ...OG, type: 'website', url: '/keys', title: TITLE, description: SUMMARY }, + openGraph: { + ...OG, + type: 'website', + url: '/keys', + title: `${TITLE} · ${NAME}`, + description: SUMMARY, + }, twitter: { ...TWITTER, title: `${TITLE} · ${NAME}`, description: SUMMARY }, } @@ -32,7 +38,9 @@ export default function Page() { return (

-

Keys

+

+ Keys +

In the shell, ? on an empty line shows these, and /keys prints them. diff --git a/site/app/kraken/page.tsx b/site/app/kraken/page.tsx new file mode 100644 index 0000000..124ff2b --- /dev/null +++ b/site/app/kraken/page.tsx @@ -0,0 +1,60 @@ +import { Guide, guideMetadata, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/kraken' +const TITLE = 'Kraken read-only API key for portfolio tracking' +const SUMMARY = + 'Which Kraken API key permissions to turn on so a tool can read balances but never withdraw, and what tula refuses.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +

+

+ Create a key with only Query Funds and Query Open Orders & Trades turned on. +

+
+ +
+

+ Kraken reports the key’s whole permission list, from an endpoint that needs no + permission of its own. A key that can trade or withdraw is refused. +

+
+ +
+

+ Spot, staked and held balances in every wallet, and open spot-margin positions. +

+
+ +
+

+ The account margin level, Kraken Futures positions and drawn credit lines. See{' '} + why that matters for ranking. +

+
+ +
+

+ Install tula, then connect with the key: +

+ {'tula connect kraken'} +

+ The same on Binance and{' '} + Coinbase. How keys are kept. +

+
+ + ) +} diff --git a/site/app/layout.tsx b/site/app/layout.tsx index 99c9f0c..b28113b 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -10,16 +10,18 @@ import { AUTHOR, DESCRIPTION, GOOGLE_SITE_VERIFICATION, + ICON, KEYWORDS, NAME, OG, REPO, SITE, TWITTER, + VERSION, } from '@/lib/site' import './globals.css' -const TITLE = 'tula — your true exposure, and what breaks first' +const TITLE = 'tula — liquidation risk and exposure across every venue' export const metadata: Metadata = { // Absolute: every canonical, OG image and sitemap URL is resolved against it, @@ -33,9 +35,13 @@ export const metadata: Metadata = { authors: [{ name: AUTHOR.name, url: AUTHOR.url }], alternates: { canonical: '/' }, // `icon` is restated beside `apple` because naming one replaces the set Next - // would have inferred from `app/icon.svg`. + // would have inferred from `app/icon.svg`. The PNG comes first: it is the one + // a search result can show. icons: { - icon: '/icon.svg', + icon: [ + { url: ICON.url, sizes: `${ICON.size}x${ICON.size}`, type: 'image/png' }, + { url: '/icon.svg', type: 'image/svg+xml' }, + ], apple: { url: APPLE_ICON.url, sizes: `${APPLE_ICON.size}x${APPLE_ICON.size}` }, }, verification: { google: GOOGLE_SITE_VERIFICATION }, @@ -92,8 +98,16 @@ const SCHEMA = { // would promise a native build the release does not produce. isAccessibleForFree: true, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + softwareVersion: VERSION, license: `${REPO}/blob/main/LICENSE`, codeRepository: REPO, + // The other places this same software is published, so a search engine + // can tell the repository, the package and the tap are one thing. + sameAs: [ + REPO, + 'https://www.npmjs.com/package/@hsnice16/tula', + 'https://github.com/hsnice16/homebrew-tap', + ], downloadUrl: `${SITE}/install/`, author: { '@id': `${SITE}/#author` }, isPartOf: { '@id': `${SITE}/#site` }, diff --git a/site/app/liquidation-risk/page.tsx b/site/app/liquidation-risk/page.tsx new file mode 100644 index 0000000..badec76 --- /dev/null +++ b/site/app/liquidation-risk/page.tsx @@ -0,0 +1,71 @@ +import { Code } from '@/components/Code' +import { Guide, guideMetadata, Output, Section } from '@/components/Guide' +import { Link } from '@/components/Link' +import { Terminal } from '@/components/Terminal' + +const PATH = '/liquidation-risk' +const TITLE = 'Liquidation risk across exchanges and DeFi, ranked' +const SUMMARY = + 'Every position that can be liquidated, across Hyperliquid, Aave and exchanges, in one list ordered by how far the price has to move.' + +export const metadata = guideMetadata(PATH, TITLE, SUMMARY) + +export default function Page() { + return ( + +
+

+ breaks lists every position that can be liquidated, nearest first, with the + price move that liquidates it. +

+ + {`VENUE ASSET KIND MOVE TO LIQ TRIGGER AS OF +─────────── ───── ────────── ─────────── ─────────────────── ───────────────── +aave ETH collateral -27.0% health factor 1.37 09:14:02 (4s ago) +hyperliquid ETH perp +39.3% liq price $3,412.00 09:14:02 (4s ago)`} + +
+ +
+

+ Aave: from the health factor.{' '} + More on Aave. +

+

+ Hyperliquid: from the liquidation + price, or the account ratio under unified or portfolio margin.{' '} + More on Hyperliquid. +

+

+ Coinbase perps: from the liquidation + price Coinbase publishes. More on Coinbase. +

+

+ A position with nothing to rank on sorts last, as unknown, never as safe. +

+
+ +
+

+ When a venue has an area tula does not read that could hold a liquidation,{' '} + breaks says so under the list. Kraken’s account margin level is one.{' '} + More on Kraken. +

+
+ +
+

+ Install tula, connect your venues, then: +

+ {'tula breaks\ntula shock ETH -20'} +
+
+ ) +} diff --git a/site/app/llms.txt/route.ts b/site/app/llms.txt/route.ts index aa3c319..6efd4c6 100644 --- a/site/app/llms.txt/route.ts +++ b/site/app/llms.txt/route.ts @@ -39,7 +39,7 @@ native and ERC-20 balances on those same nine chains — all three from one publ address alone. Then Kraken, Binance, Coinbase Advanced and Stripe, each from a read-only key. A key proven to withdraw or trade is turned away rather than warned about; where a venue exposes no way to check — -Kraken for trading, Stripe for both — tula says so rather than calling it safe. +Stripe, for both powers — tula says so rather than calling it safe. A venue that publishes no read-only key at all is not offered: there is nothing to connect it with that tula would hold. A venue may hold more than one address or key, and every figure counts all of them. diff --git a/site/app/not-found.tsx b/site/app/not-found.tsx index 6049b83..5f54121 100644 --- a/site/app/not-found.tsx +++ b/site/app/not-found.tsx @@ -3,7 +3,7 @@ import { Link } from '@/components/Link' import { NAME, NAV, OG, TWITTER } from '@/lib/site' const TITLE = 'Not found' -const SUMMARY = 'There is nothing at this address. Everything the site publishes is listed here.' +const SUMMARY = 'There is nothing at this address. The site’s main pages are listed here.' export const metadata: Metadata = { // Without one, every 404 carries the front page's title. @@ -35,17 +35,16 @@ export default function NotFound() { return (

404

-

+

There is nothing at this address.

- The link may have moved, or the address may be mistyped. Everything the site publishes is - below. + The link may have moved, or the address may be mistyped. The main pages are below.

-

Every page

+

Pages

- {NAV.map(({ href, label, blurb }) => ( + {NAV.filter((n) => n.group === 'site').map(({ href, label, blurb }) => (
{/* A 12px line of capitals is a 14px-tall target, and these links diff --git a/site/app/page.tsx b/site/app/page.tsx index 4f3c3ed..aaf5d16 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -75,8 +75,8 @@ export default function Page() {

Every venue weighs only what it holds. - - tula weighs what you hold. + {' '} + tula weighs what you hold.

Your true exposure, what breaks first, and more, across every venue at once. diff --git a/site/app/security/page.tsx b/site/app/security/page.tsx index 9f93a42..b215b04 100644 --- a/site/app/security/page.tsx +++ b/site/app/security/page.tsx @@ -6,13 +6,19 @@ import { NAME, OG, REPO, TWITTER } from '@/lib/site' const TITLE = 'Security model — non-custodial, and read-only' const SUMMARY = - 'What tula promises about your keys and your funds, and what enforces each promise: no code path can move funds off a venue, a key that can withdraw is turned away, and your keys never reach the model.' + 'What tula promises about your keys and your funds, and what enforces each promise. No code path can move funds off a venue, and your keys never reach the model.' export const metadata: Metadata = { title: TITLE, description: SUMMARY, alternates: { canonical: '/security' }, - openGraph: { ...OG, type: 'website', url: '/security', title: TITLE, description: SUMMARY }, + openGraph: { + ...OG, + type: 'website', + url: '/security', + title: `${TITLE} · ${NAME}`, + description: SUMMARY, + }, twitter: { ...TWITTER, title: `${TITLE} · ${NAME}`, description: SUMMARY }, } @@ -46,7 +52,7 @@ const NOTES = [ ], [ 'Unknown is a value', - 'Kraken proves a key cannot withdraw, not that it cannot trade, so tula says unknown rather than safe. A missing price shows as missing, never as zero.', + 'Stripe publishes no way to check what a key can do, so tula says unknown rather than safe. A missing price shows as missing, never as zero.', ], [ 'The model never computes', @@ -62,7 +68,7 @@ const NOTES = [ ], [ 'What tula connects to', - 'The venues you connect, your price source, token lists, and public nodes for Ethereum, Arbitrum One, Base, Polygon, Optimism, Avalanche, Gnosis, Scroll and Linea. If a node is down tula tries the next one, which then also sees your address. Anthropic gets only finished numbers, and only when you ask a question. The shell checks GitHub for a new release once a day. The binary tracks nothing; this site uses Google Analytics.', + 'The venues you connect, your price source, token lists, and public nodes for Ethereum, Arbitrum One, Base, Polygon, Optimism, Avalanche, Gnosis, Scroll and Linea. If a node is down tula tries the next one, which then also sees your address. Anthropic gets only finished numbers, and only when you ask a question. The shell checks GitHub for a new release each time it opens. The binary tracks nothing; this site uses Google Analytics and shows a Peerlist badge.', ], ] as const @@ -70,7 +76,7 @@ export default function Page() { return (

-

+

Security model

{/* Two paragraphs: run together, the second sentence starts mid-line. */} diff --git a/site/app/sitemap.ts b/site/app/sitemap.ts index 3bd3a53..fa53f6e 100644 --- a/site/app/sitemap.ts +++ b/site/app/sitemap.ts @@ -8,24 +8,11 @@ export const dynamic = 'force-static' * Every readable page is `NAV` plus `llms.txt`, derived rather than listed so a * new page cannot ship unindexed. What is left out is what a reader would * never arrive at: the 404, the assets, `install.sh` and `security.txt`. + * + * URLs only. Google ignores `priority` and `changefreq`, and uses `lastmod` + * only where it is accurate — the build time on every URL is not, and the + * deploy's shallow clone cannot give each page its own date. */ export default function sitemap(): MetadataRoute.Sitemap { - // The site is static, so "last modified" is the build that published it — - // which is the only change a crawler could ever be told about. - const built = new Date() - - return [ - ...NAV.map(({ href }) => ({ - url: pageUrl(href), - lastModified: built, - changeFrequency: 'monthly' as const, - priority: href === '/' ? 1 : 0.8, - })), - { - url: `${SITE}/llms.txt`, - lastModified: built, - changeFrequency: 'monthly' as const, - priority: 0.5, - }, - ] + return [...NAV.map(({ href }) => ({ url: pageUrl(href) })), { url: `${SITE}/llms.txt` }] } diff --git a/site/components/Channels.tsx b/site/components/Channels.tsx index 20719a6..b334c54 100644 --- a/site/components/Channels.tsx +++ b/site/components/Channels.tsx @@ -13,9 +13,9 @@ export interface Channel { const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-') /** - * One install channel at a time. All three used to run down the page at once, - * and every section under them branched three ways in prose — a reader on any - * one channel read two answers that were not theirs to find the one that was. + * One install channel at a time. With all three down the page, every section + * under them branches three ways, and a reader on one channel reads two answers + * that are not theirs to find the one that is. * * Panels hide rather than unmount, so the static export ships all three in the * HTML and a crawler indexes the whole page, not whichever tab happened to be diff --git a/site/components/Copy.tsx b/site/components/Copy.tsx index 3ca8b2b..3f8a333 100644 --- a/site/components/Copy.tsx +++ b/site/components/Copy.tsx @@ -20,10 +20,10 @@ const WORDS: Record = { * takes the tap area to 44 without moving anything, which is the shape this * has to be when the control a phone reader actually presses lives in a strip * sized for a desktop pointer. - * It takes the text - * as a prop rather than reading it back out of the DOM, so what lands on the - * clipboard is the string the page was built from — a command a reader pastes - * into a shell cannot be whatever the rendering happened to leave behind. + * + * It takes the text as a prop rather than reading it back out of the DOM, so + * what lands on the clipboard is the string the page was built from — a command + * a reader pastes into a shell cannot be whatever the rendering left behind. * * The clipboard can refuse: a browser withholding the permission, or a page * opened over plain HTTP. A button that stays on `Copy` after a click reads as diff --git a/site/components/Ext.tsx b/site/components/Ext.tsx index 3b261c5..f97f13c 100644 --- a/site/components/Ext.tsx +++ b/site/components/Ext.tsx @@ -5,24 +5,33 @@ import type { AnchorHTMLAttributes } from 'react' * routes only — see AGENTS.md. `rel` is not optional with a `_blank` target: * without it the opened page gets a handle on this one through `window.opener`. */ -export function Ext({ children, ...props }: AnchorHTMLAttributes) { +export function Ext({ + children, + bare, + ...props +}: AnchorHTMLAttributes & { + /** For a link whose content is an image: the arrow has nothing to follow. */ + bare?: boolean +}) { return (
{children} - {/* No whitespace before this tag: a space here is a wrap point, and would - let the icon fall to its own line, orphaned from the last word. */} - + {bare ? null : ( + // No whitespace before this tag: a space here is a wrap point, and + // would let the icon fall to its own line, orphaned from the last word. + + )} ) } diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index 1c0d621..3ecae1b 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -1,48 +1,112 @@ +import type { ReactNode } from 'react' import { Ext } from '@/components/Ext' import { Link } from '@/components/Link' -import { NAV, REPO, SITE } from '@/lib/site' +import { AUTHOR, NAV, REPO, SITE } from '@/lib/site' + +/** Peerlist's own embed, at the size it is served, so the footer does not move when it loads. */ +const PEERLIST = { + href: 'https://peerlist.io/hsnice16/project/tula', + src: 'https://peerlist.io/api/v1/projects/embed/PRJH6A7Q86ELK9DE939A87ND8MAKE6?showUpvote=true&theme=dark', + width: 296, + height: 72, +} as const /** - * The `after` takes a 29px link to a 44px target without moving it. The rows - * are gap-y-4 apart so that, wrapped on a phone, one row's targets do not - * overlap the next and hand it their taps. + * A 32px row per link: a list that reads as one, with each target above WCAG + * 2.2's 24px minimum. Stacked links cannot borrow target from the space around + * them without handing their taps to the next. */ const LINK = - "relative py-1 text-dim hover:text-accent after:absolute after:-inset-x-[7px] after:-inset-y-[7.5px] after:content-['']" + 'flex min-h-11 items-center text-dim underline decoration-rule decoration-dotted underline-offset-4 hover:text-accent' + +function Column({ + title, + className = '', + children, +}: { + title: string + className?: string + children: ReactNode +}) { + return ( + + ) +} export function Footer() { return ( -