diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..77d175a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + cargo: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + # release.yml is generated by `dist generate`; its actions are pinned in + # dist-workspace.toml ([dist.github-action-commits]) and updated there. + ignore: + - dependency-name: actions/checkout + - dependency-name: actions/upload-artifact + - dependency-name: actions/download-artifact + - dependency-name: actions/setup-node + - dependency-name: actions/attest diff --git a/.github/workflows/api-contract.yml b/.github/workflows/api-contract.yml new file mode 100644 index 0000000..707d763 --- /dev/null +++ b/.github/workflows/api-contract.yml @@ -0,0 +1,22 @@ +name: API contract + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + pull_request: + paths: + - src/model.rs + - src/commands.rs + - scripts/check-api-contract.py + - .github/workflows/api-contract.yml + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - run: python3 scripts/check-api-contract.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ddfabe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + components: rustfmt, clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets --locked -- -D warnings + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - run: cargo test --locked + + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: "1.88" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - run: cargo check --locked + + audit: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000..e891c07 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,34 @@ +name: Maintenance + +on: + schedule: + - cron: "41 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + credentials: + runs-on: ubuntu-latest + steps: + - name: Check release credentials + env: + TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + headers=$(curl -sS -o /dev/null -D - -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" \ + https://api.github.com/repos/BlockIo/homebrew-tap) + status=$(printf '%s' "$headers" | tail -n 1) + if [ "$status" != "200" ]; then + echo "::error::HOMEBREW_TAP_TOKEN was rejected (HTTP $status); replace it" + exit 1 + fi + expires=$(printf '%s' "$headers" | tr -d '\r' \ + | sed -n 's/^github-authentication-token-expiration: //Ip') + [ -z "$expires" ] && exit 0 + days=$(( ($(date -d "$expires" +%s) - $(date +%s)) / 86400 )) + if [ "$days" -lt 30 ]; then + echo "::error::HOMEBREW_TAP_TOKEN expires in $days days; replace it" + exit 1 + fi diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..6b45a41 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,62 @@ +# Publishes the npm package. Called by release.yml, or run by hand for a tag. +name: Publish npm + +on: + workflow_call: + inputs: + plan: + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Release tag to publish, e.g. v0.1.1 + required: true + type: string + +permissions: {} + +jobs: + publish: + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + steps: + - name: Fetch the npm package from this run + if: ${{ inputs.plan != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: artifacts-* + path: npm/ + merge-multiple: true + + - name: Fetch the npm package from the GitHub release + if: ${{ inputs.plan == '' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern '*-npm-package.tar.gz' --dir npm/ + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24.x" + registry-url: "https://registry.npmjs.org" + + - name: Update npm + run: npm install --global npm@latest + + - name: Publish + run: | + npm --version + shopt -s nullglob + packages=(npm/*-npm-package.tar.gz) + if [ ${#packages[@]} -eq 0 ]; then + echo "no npm package found" >&2 + exit 1 + fi + for pkg in "${packages[@]}"; do + npm stage publish --access public "./$pkg" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..935b6d8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,366 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.33.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + permissions: + "attestations": "write" + "contents": "read" + "id-token": "write" + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - name: Attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 + with: + subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: true + repository: "BlockIo/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + custom-publish-npm: + needs: + - plan + - host + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + uses: ./.github/workflows/publish-npm.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # publish jobs get escalated permissions + permissions: + "contents": "read" + "id-token": "write" + + announce: + needs: + - plan + - host + - publish-homebrew-formula + - custom-publish-npm + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.custom-publish-npm.result == 'skipped' || needs.custom-publish-npm.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c0af5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..83afb99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# Using `sochain` from scripts and AI agents + +`sochain` reads Bitcoin, Litecoin, Dogecoin and Zcash blockchain data from +SoChain. It is read-only: it never needs private keys and never sends +transactions. + +## Rules of thumb + +- Every lookup names its network first: `BTC`, `LTC`, `DOGE`, `ZEC`, or a + testnet (`BTCTEST`, `LTCTEST`, `DOGETEST`, `ZECTEST`), in any case. +- Add `--json` to any command (or set `SOCHAIN_JSON=1`). stdout is then the + API's reply, unchanged: `{"status":"success","data":{...}}`. (`account` + prints the account object without the envelope, as the API sends it.) +- On failure, stdout is empty and stderr has + `{"status":"fail","data":{"error_message":"...","code":"..."}}`. +- Branch on the exit code: + + | Exit | Code | Meaning | + |------|----------------|-----------------------------------------------------| + | 0 | | ok | + | 1 | `OTHER` | anything else | + | 2 | `BAD_INPUT` | invalid arguments, or the API rejected the input | + | 3 | `NOT_FOUND` | no such network, address, transaction or block | + | 4 | `UNAVAILABLE` | SoChain unreachable or busy; retry later | + | 5 | `RATE_LIMITED` | still rate limited after waiting and retrying | + | 6 | `UNAUTHORIZED` | API key missing, invalid, or out of requests | + +- Amounts are decimal strings in the network's coin with 8 decimals. Parse + them as decimals (or integers of the smallest unit), not floats. Times are + unix seconds. +- Hashes, transaction ids and addresses are always printed in full. +- `sochain schema` prints every command, its API endpoint and its output + shape as JSON. + +## Common tasks + +```sh +sochain balance BTC
--json # confirmed + unconfirmed +sochain balance BTC
--time 2025-12-31 --json # at a past time +sochain address-summary LTC
--json # totals, first/last activity +sochain transactions DOGE
--json # 10 latest; --page 2 for more +sochain transactions BTC
--from-time 2025-01-01 --direction in --json + # filtered; follow data.next_cursor with --cursor +sochain transaction BTC --json # inputs, outputs, fee +sochain transaction-status BTC --json # pending, mined, replaced, dropped... +sochain block LTC latest --json +sochain fees BTC --json # data.suggested_fee_rate (sat/vB) +sochain price DOGE --json # add --currency EUR for euros +sochain address-poisoning BTC
--json # look-alike senders +sochain chart BTC tx_count --interval day --json +``` + +## API key + +Lookups work without a key: 2,500 a day per IP address. With a key, the +account's plan applies. The CLI reads `SOCHAIN_API_KEY`, or a key saved by +`sochain auth login`; `sochain auth status` shows which is used. Never print +the key or pass it on the command line. + +## Teach your agent + +`sochain skill install` installs the SoChain skill for Claude Code and Codex +(`--project` for the current project only). `sochain skill status` shows +whether it is current. + +`sochain mcp install` adds the SoChain MCP server (`https://sochain.com/mcp`) +to Codex and Claude Code; `--use-api-key` makes it use your key, and `--print` +shows the setup for other apps. + +## Safety + +The CLI refuses input that looks like a private key or recovery phrase (exit +2, `BAD_INPUT`) without sending it anywhere. Never pass one to it, or to any +website or API. + +`address-poisoning` lists look-alike addresses that sent an address dust to +trick its owner into copying them. Surface any finding to the user, and never +suggest copying an address from transaction history. + +## Limits + +SoChain answers at most a few requests at once per account or IP address. +When it asks the CLI to slow down, the CLI waits (up to 30 seconds, 3 times) +and retries before exiting with code 5. When the day's keyless lookups are +used up, it exits with code 5 at once, saying when to try again. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a512035 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project +follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The public interface covered by semantic versioning is: command and flag +names, environment variables, exit codes, error codes, and the shape of +`--json` output (which is the SoChain API's reply). + +## [Unreleased] + +## [0.1.0] - 2026-10-10 + +### Added + +- Lookups for Bitcoin, Litecoin, Dogecoin, Zcash and their testnets: + `balance`, `address-summary`, `transactions` (paged, filtered or CSV), + `transaction-counts`, `unspent-outputs`, `is-valid-address`, `transaction`, + `transaction-status`, `block`, `latest-blocks`, `best-block-hash`, + `network-info`, `fees`, `price`, `rich-list`, `address-rank`, + `balance-history`, `address-poisoning`, `address-counterparties`, `chart` + and `account`. +- Plain-English output by default; `--json` for the API's reply. +- `--currency` for prices in 30 currencies. +- `auth login`, `logout` and `status` to save and check an API key. +- `skill` and `mcp` to set up AI agents. +- `schema` and `guide` for scripts and agents. + +[Unreleased]: https://github.com/BlockIo/sochain-cli/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/BlockIo/sochain-cli/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..207fdb0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2113 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "sochain-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "predicates", + "reqwest", + "rpassword", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..452d3ef --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "sochain-cli" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Command-line interface for SoChain: Bitcoin, Litecoin, Dogecoin and Zcash blockchain data" +repository = "https://github.com/BlockIo/sochain-cli" +homepage = "https://sochain.com" +readme = "README.md" +keywords = ["bitcoin", "litecoin", "dogecoin", "zcash", "cli"] +categories = ["command-line-utilities", "cryptography::cryptocurrencies"] +license = "MIT" +authors = ["Block.io, Inc."] + +[[bin]] +name = "sochain" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } +reqwest = { version = "0.13", features = ["blocking", "json"] } +rpassword = "7" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.11" +thiserror = "2.0" + +[dev-dependencies] +assert_cmd = "2.2" +predicates = "3.1" +sha2 = "0.11" +tempfile = "3" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md new file mode 100644 index 0000000..12490d1 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# sochain + +Bitcoin, Litecoin, Dogecoin and Zcash blockchain data from your terminal, via +[SoChain](https://sochain.com). + +```console +$ sochain balance BTC bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh +Balance 3.92247777 BTC + +$ sochain fees BTC +Suggested fee rate 0.25 sat/vB (the next block's median). +... + +$ sochain transaction LTC +$ sochain block DOGE latest +$ sochain transactions BTC
--from-time 2025-01-01 --direction in +``` + +Output is plain English by default. Add `--json` to any command for the SoChain +API's reply, unchanged, for scripts and AI agents. + +`sochain` is read-only: it never needs private keys and never sends +transactions. Lookups work without an account; an API key raises the limits. + +## Install + +**Homebrew (macOS and Linux)** + +```sh +brew install blockio/tap/sochain +``` + +**npm (any platform with Node.js)** + +```sh +npm install -g sochain-cli # or run once with: npx sochain-cli +``` + +**macOS and Linux, without Homebrew** + +```sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/BlockIo/sochain-cli/releases/latest/download/sochain-cli-installer.sh | sh +``` + +**Windows (PowerShell)** + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://github.com/BlockIo/sochain-cli/releases/latest/download/sochain-cli-installer.ps1 | iex" +``` + +**From source** (Rust 1.88 or newer) + +```sh +cargo install --git https://github.com/BlockIo/sochain-cli --locked +``` + +Prebuilt binaries for each platform are attached to every +[release](https://github.com/BlockIo/sochain-cli/releases). + +## Networks + +Every lookup names its network first, in any case: + +| Code | Network | +|---|---| +| `BTC` | Bitcoin | +| `LTC` | Litecoin | +| `DOGE` | Dogecoin | +| `ZEC` | Zcash (transparent addresses and transactions) | +| `BTCTEST`, `LTCTEST`, `DOGETEST`, `ZECTEST` | Their testnets | + +## Commands + +| Command | Shows | +|---|---| +| `balance
[--height N \| --time T]` | Confirmed and unconfirmed balance, or the balance at a past block or time | +| `address-summary
` | Balance, totals received, transaction counts, first and last activity (alias `address`) | +| `transactions
[--page N]` | Transactions, 10 per page, newest first (alias `txs`) | +| `transactions
[filters] [--csv]` | Filtered by `--from-time`, `--to-time`, `--from-height`, `--to-height`, `--direction`, `--unconfirmed`; paged by `--cursor`; CSV export | +| `transaction-counts
` | Sent, received and total (alias `counts`) | +| `unspent-outputs
[--page N]` | Spendable outputs (aliases `utxos`, `unspent`) | +| `is-valid-address
` | Whether an address is valid on a network (alias `validate`) | +| `transaction ` | Confirmations, inputs, outputs and fee (alias `tx`) | +| `transaction-status ` | Pending, mined, replaced, dropped, expired or unknown (alias `status`) | +| `block ` | A block | +| `latest-blocks ` | The 10 newest blocks (alias `blocks`) | +| `best-block-hash ` | The newest block's hash | +| `network-info ` | Block count, hashrate, the mempool (alias `network`) | +| `fees ` | Fee rates for the next blocks, and a suggested rate | +| `price [--time T]` | The latest price, or a day's opening price | +| `rich-list [--page N] [--page-size N]` | The top 1,000 holders (aliases `richlist`, `top`) | +| `address-rank
` | Rank among the largest holders, and share of supply (alias `rank`) | +| `balance-history
[--interval day\|week\|month]` | Balance over time, with amounts in and out | +| `address-poisoning
` | Look-alike addresses that sent dust to trick the owner (alias `poisoning`) | +| `address-counterparties
` | Who it paid and was paid by lately (alias `counterparties`) | +| `chart [--interval I]` | A chart series as a table, such as `tx_count`, `hashrate` or `price_usd` | +| `account` | Your plan and requests used (needs an API key) | +| `auth login` / `logout` / `status` | Save, remove or check your API key | +| `skill install` / `status` / `uninstall` | The SoChain skill for AI agents (Claude Code, Codex) | +| `mcp install` / `status` / `uninstall` | The SoChain MCP server for AI agents (Codex, Claude Code; `--print` for others) | +| `schema` | Every command's API endpoint and output shape, as JSON | +| `guide` | A usage guide for scripts and AI agents | + +`--currency EUR` (or any of 30 currencies) shows prices in that currency +instead of US dollars. Run `sochain --help` for details and examples. + +## API key + +Lookups work without a key, up to 2,500 a day per IP address. For more, get a +key at [sochain.com/signin](https://sochain.com/signin) (plans and limits: +[sochain.com/pricing](https://sochain.com/pricing)), then either: + +```sh +sochain auth login # paste the key at the hidden prompt +export SOCHAIN_API_KEY=... # or set it in the environment +``` + +`auth login` checks the key with SoChain and saves it in your config folder +(`~/.config/sochain` on macOS and Linux, `%APPDATA%\sochain` on Windows), +readable only by you. `SOCHAIN_API_KEY` takes precedence when set. The key is +sent only to sochain.com, and never printed. + +## Scripts and AI agents + +- `--json` (or `SOCHAIN_JSON=1`) prints the API's reply on stdout: + `{"status":"success","data":{...}}`. Errors are then JSON on stderr: + `{"status":"fail","data":{"error_message":"...","code":"..."}}`. +- Exit codes: 0 ok, 1 other error, 2 bad input, 3 not found, 4 SoChain + unavailable, 5 rate limited, 6 API key missing, invalid or out of requests. +- Amounts are decimal strings with 8 decimals; parse them as decimals, not + floats. Times are unix seconds. +- `sochain guide` prints a guide for agents ([AGENTS.md](AGENTS.md)); + `sochain schema` describes every command as JSON. +- `sochain skill install` adds the SoChain skill to Claude Code and Codex. +- `sochain mcp install` adds the hosted MCP server, `https://sochain.com/mcp`, + to Codex and Claude Code (`--use-api-key` to use your key). By hand: + + ```sh + codex mcp add sochain --url https://sochain.com/mcp --bearer-token-env-var SOCHAIN_API_KEY + claude mcp add --transport http sochain https://sochain.com/mcp --header "API-KEY: $SOCHAIN_API_KEY" + ``` + + For Cursor and other clients, use the URL with an `API-KEY` header; + `sochain mcp install --print` shows each app's setup. + +## Safety + +The CLI refuses input that looks like a private key or recovery phrase, +without sending it anywhere. Text from the API is stripped of terminal control +characters before it is printed. + +## Settings + +| Variable | Flag | Meaning | +|---|---|---| +| `SOCHAIN_API_KEY` | | API key (else the one saved by `auth login`) | +| `SOCHAIN_JSON` | `--json` | JSON output | +| `SOCHAIN_TIMEOUT` | `--timeout` | Seconds to wait for a reply (default 30) | +| `SOCHAIN_CURRENCY` | `--currency` | Currency for prices (default USD) | +| `SOCHAIN_CONFIG_DIR` | | Where `auth login` saves the key | + +## Links + +- API documentation: https://sochain.com/docs +- OpenAPI description: https://sochain.com/api/v3/openapi.json +- Status: https://status.sochain.com +- Terms of Service: https://sochain.com/terms + +## License + +MIT. Data comes from SoChain and is subject to its +[Terms of Service](https://sochain.com/terms). diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..ae21cad --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,37 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.33.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "homebrew", "npm"] +# A GitHub repo to push Homebrew formulas to +tap = "BlockIo/homebrew-tap" +# Formula name, so users run `brew install blockio/tap/sochain` +formula = "sochain" +# Publish jobs to run in CI +# npm publishes via publish-npm.yml +publish-jobs = ["homebrew", "./publish-npm"] +# npm package name, so users run `npm install -g sochain-cli` or `npx sochain-cli` +npm-package = "sochain-cli" +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false +github-custom-job-permissions = { "publish-npm" = { contents = "read", id-token = "write" } } +# Sign release artifacts with GitHub build provenance attestations +github-attestations = true + +# Pin third-party actions to exact commits +[dist.github-action-commits] +"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803" # v6 +"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7 +"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" # v8 +"actions/setup-node" = "249970729cb0ef3589644e2896645e5dc5ba9c38" # v6 +"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6" # v4 diff --git a/scripts/check-api-contract.py b/scripts/check-api-contract.py new file mode 100644 index 0000000..4457724 --- /dev/null +++ b/scripts/check-api-contract.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Checks the SoChain OpenAPI description against what the CLI relies on: + +1. every endpoint the CLI calls is still a GET in the spec, and +2. every field the text output requires (the non-Option fields of + src/model.rs) is still present and never null. + +Keep the lists below in step with src/commands.rs and src/model.rs. +Usage: check-api-contract.py [spec.json] (default: the live spec) +""" + +import json +import sys +import urllib.request + +SPEC_URL = "https://sochain.com/api/v3/openapi.json" + +ENDPOINTS = [ + "/balance/{network}/{address}", + "/address_summary/{network}/{address}", + "/transactions/{network}/{address}/{page}", + "/transactions/{network}/{address}", + "/transaction_counts/{network}/{address}", + "/unspent_outputs/{network}/{address}/{page}", + "/is_valid_address/{network}/{address}", + "/transaction/{network}/{hash}", + "/transaction_status/{network}/{hash}", + "/block/{network}/{hash_or_height}", + "/latest_blocks_summary/{network}", + "/best_block_hash/{network}", + "/network_info/{network}", + "/price/{network}", + "/price/{network}/{unix_timestamp}", + "/rich_list/{network}", + "/address_rank/{network}/{address}", + "/balance_history/{network}/{address}", + "/address_poisoning/{network}/{address}", + "/address_counterparties/{network}/{address}", + "/charts/{network}/{series}/{interval}", + "/account_info", +] + +# (component schema, path to an object inside it, fields the CLI requires) +REQUIRED = [ + ("Balance", [], ["confirmed"]), + ("AddressSummary", [], ["txs_sent", "txs_received", "txs_total", "confirmed_balance", + "unconfirmed_balance", "confirmed_received"]), + ("AddressTransactions", [], ["transactions"]), + ("AddressTransaction", [], ["hash", "balance_change", "time"]), + ("TransactionCounts", [], ["sent", "received", "total"]), + ("UnspentOutputs", [], ["outputs"]), + ("UnspentOutput", [], ["hash", "index", "value"]), + ("IsValidAddress", [], ["is_valid"]), + ("Transaction", [], ["hash", "confirmations", "size", "time", "fee", "inputs", "outputs"]), + ("TransactionOutput", [], ["value"]), + ("TransactionStatus", [], ["status"]), + ("Block", [], ["height", "hash", "time", "confirmations", "num_txs", "size", + "difficulty", "reward", "fees", "value_out"]), + ("LatestBlocks", [], ["blocks"]), + ("LatestBlock", [], ["height", "time", "num_txs", "reward_and_fees"]), + ("BestBlockHash", [], ["hash"]), + ("NetworkInfo", [], ["block_count", "best_block_hash", "hashrate", "mempool"]), + ("NetworkInfo", ["mempool"], ["mempool_txs", "mempool_size"]), + ("ProjectedBlock", [], ["block_num", "num_txs", "min_fee_rate", "median_fee_rate", + "max_fee_rate"]), + ("PriceData", [], ["value", "currency"]), + ("RichList", [], ["height", "supply", "page", "page_size", "total_rows", "rows"]), + ("RichList", ["rows", "[]"], ["rank", "balance"]), + ("AddressRank", [], ["address", "balance"]), + ("BalanceHistory", [], ["interval", "points"]), + ("BalanceHistory", ["points", "[]"], ["t", "v", "received", "sent"]), + ("AddressPoisoning", [], ["address", "findings"]), + ("AddressPoisoning", ["findings", "[]"], ["sender", "imitates"]), + ("Counterparties", [], ["address", "counterparties"]), + ("Counterparties", ["counterparties", "[]"], ["address", "direction", "amount", "txid"]), + ("Chart", [], ["series", "interval", "points"]), + ("ChartPoint", [], ["t"]), +] + + +def main(): + if len(sys.argv) > 1: + with open(sys.argv[1]) as f: + spec = json.load(f) + else: + request = urllib.request.Request( + SPEC_URL, headers={"User-Agent": "sochain-cli-contract-check"} + ) + with urllib.request.urlopen(request, timeout=30) as r: + spec = json.load(r) + + def res(s): + while isinstance(s, dict) and "$ref" in s: + node = spec + for key in s["$ref"].lstrip("#/").split("/"): + node = node[key] + s = node + return s + + def merge(s): + s = res(s) + if "allOf" in s: + m = {"properties": {}, "required": []} + for part in s["allOf"]: + part = merge(part) + m["properties"].update(part.get("properties", {})) + m["required"] += part.get("required", []) + return m + return s + + def nullable(s): + s = res(s) + t = s.get("type") + if t == "null" or (isinstance(t, list) and "null" in t): + return True + if None in s.get("enum", []): + return True + return any(nullable(x) for x in s.get("anyOf", []) + s.get("oneOf", [])) + + problems = [] + paths = spec.get("paths", {}) + for endpoint in ENDPOINTS: + if "get" not in paths.get(endpoint, {}): + problems.append(f"GET {endpoint} is not in the spec") + + schemas = spec.get("components", {}).get("schemas", {}) + for name, inner, fields in REQUIRED: + if name not in schemas: + problems.append(f"schema {name} is missing") + continue + node = merge(schemas[name]) + where = name + for step in inner: + node = merge(node["items"] if step == "[]" else node.get("properties", {}).get(step, {})) + where += f".{step}" + props = node.get("properties", {}) + declared = node.get("required") + for field in fields: + if field not in props: + problems.append(f"{where}.{field} is gone") + elif declared is not None and field not in declared: + problems.append(f"{where}.{field} is no longer required") + elif nullable(props[field]): + problems.append(f"{where}.{field} can now be null") + + if problems: + print("The SoChain API no longer guarantees what the CLI relies on:") + for p in problems: + print(f" - {p}") + sys.exit(1) + print(f"ok: {len(ENDPOINTS)} endpoints and {sum(len(f) for _, _, f in REQUIRED)} fields") + + +if __name__ == "__main__": + main() diff --git a/src/amount.rs b/src/amount.rs new file mode 100644 index 0000000..9f7d3a6 --- /dev/null +++ b/src/amount.rs @@ -0,0 +1,153 @@ +//! Coin amounts. The API sends them as decimal strings with 8 decimal places; +//! they are held as whole units of the smallest denomination (satoshis, +//! litoshis, koinu, zatoshis: 100,000,000 to the coin) so no precision is lost +//! to floating point. + +use std::fmt; + +use serde::{Deserialize, Deserializer, de}; + +pub const UNITS_PER_COIN: i128 = 100_000_000; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Amount(pub i128); + +impl Amount { + /// Parses a decimal string such as `"12400.5"` or `"-0.00000001"`. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let (negative, digits) = match s.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, s.strip_prefix('+').unwrap_or(s)), + }; + let (whole, frac) = digits.split_once('.').unwrap_or((digits, "")); + if whole.is_empty() && frac.is_empty() + || frac.len() > 8 + || !whole.bytes().all(|b| b.is_ascii_digit()) + || !frac.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + let whole: i128 = if whole.is_empty() { + 0 + } else { + whole.parse().ok()? + }; + let frac: i128 = format!("{frac:0<8}").parse().ok()?; + let units = whole.checked_mul(UNITS_PER_COIN)?.checked_add(frac)?; + Some(Amount(if negative { -units } else { units })) + } + + pub fn is_negative(self) -> bool { + self.0 < 0 + } + + pub fn is_zero(self) -> bool { + self.0 == 0 + } + + pub fn abs(self) -> Amount { + Amount(self.0.abs()) + } + + pub fn to_f64(self) -> f64 { + self.0 as f64 / UNITS_PER_COIN as f64 + } + + /// At most `places` decimal places (rounded half away from zero), with + /// thousands separators and trailing zeros removed: `1,234.5`. + pub fn display(self, places: u32) -> String { + let places = places.min(8); + let step = 10i128.pow(8 - places); + let rounded = (self.0.abs() + step / 2) / step * step; + let whole = rounded / UNITS_PER_COIN; + let frac = rounded % UNITS_PER_COIN; + let mut out = String::new(); + if self.0 < 0 && rounded != 0 { + out.push('-'); + } + out.push_str(&group_thousands(whole)); + let frac = format!("{frac:08}"); + let frac = frac[..places as usize].trim_end_matches('0'); + if !frac.is_empty() { + out.push('.'); + out.push_str(frac); + } + out + } +} + +/// Full precision, trailing zeros removed. +impl fmt::Display for Amount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.display(8)) + } +} + +impl<'de> Deserialize<'de> for Amount { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + Amount::parse(&s).ok_or_else(|| de::Error::custom(format!("invalid amount {s:?}"))) + } +} + +pub fn group_thousands(n: i128) -> String { + let digits = n.abs().to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + if n < 0 { + out.insert(0, '-'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_decimal_strings() { + assert_eq!( + Amount::parse("12400.00000000"), + Some(Amount(12400 * UNITS_PER_COIN)) + ); + assert_eq!(Amount::parse("0.00000001"), Some(Amount(1))); + assert_eq!(Amount::parse("-1.5"), Some(Amount(-150_000_000))); + assert_eq!(Amount::parse(".5"), Some(Amount(50_000_000))); + assert_eq!(Amount::parse("7"), Some(Amount(7 * UNITS_PER_COIN))); + } + + #[test] + fn rejects_malformed_amounts() { + for bad in ["", "-", ".", "1.123456789", "1e5", "abc", "1.2.3", " - 1"] { + assert_eq!(Amount::parse(bad), None, "{bad:?}"); + } + } + + #[test] + fn displays_with_grouping_and_rounding() { + let a = Amount::parse("1234567.89000000").unwrap(); + assert_eq!(a.to_string(), "1,234,567.89"); + assert_eq!(a.display(0), "1,234,568"); + assert_eq!( + Amount::parse("0.00000001").unwrap().to_string(), + "0.00000001" + ); + assert_eq!(Amount::parse("0.00000001").unwrap().display(2), "0"); + assert_eq!(Amount::parse("-2.5").unwrap().display(0), "-3"); + assert_eq!(Amount::parse("-0.001").unwrap().display(2), "0"); + } + + #[test] + fn groups_thousands() { + assert_eq!(group_thousands(0), "0"); + assert_eq!(group_thousands(999), "999"); + assert_eq!(group_thousands(1000), "1,000"); + assert_eq!(group_thousands(-1234567), "-1,234,567"); + } +} diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..a670b10 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,344 @@ +//! HTTP client for the SoChain API (https://sochain.com/api/v3). + +use std::thread::sleep; +use std::time::Duration; + +use reqwest::StatusCode; +use reqwest::Url; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, RETRY_AFTER, WARNING}; +use serde_json::Value; + +use crate::error::{CliError, Result}; +use crate::sanitize; + +/// The only host the CLI talks to. +pub const SITE: &str = "https://sochain.com"; + +/// How many times a rate-limited request is retried, and the longest +/// Retry-After the CLI will sit out before giving up. +const RATE_LIMIT_RETRIES: u32 = 3; +const MAX_RETRY_WAIT_SECS: u64 = 30; + +pub struct Api { + base: Url, + http: Client, + timeout: Duration, + key: Option, + /// Print notes (rate-limit waits, `Warning` headers) on stderr. Off for + /// --json, where stderr carries only the JSON error. + notes: bool, +} + +/// A successful reply, before its body is interpreted. +pub struct Raw { + pub body: Vec, + pub headers: HeaderMap, +} + +impl Api { + pub fn new(timeout: Duration, key: Option, notes: bool) -> Result { + let base = Url::parse(&base_url()) + .map_err(|e| CliError::Other(format!("invalid API URL: {e}")))?; + // Follow redirects only within the same host, so neither a request + // nor the API key can be sent elsewhere. + let redirects = reqwest::redirect::Policy::custom(|attempt| { + let same_host = attempt + .previous() + .first() + .is_some_and(|first| first.host_str() == attempt.url().host_str()); + if attempt.previous().len() > 5 || !same_host { + attempt.stop() + } else { + attempt.follow() + } + }); + let http = Client::builder() + .user_agent(concat!("sochain-cli/", env!("CARGO_PKG_VERSION"))) + .redirect(redirects) + .connect_timeout(timeout) + .build() + .map_err(|e| CliError::Other(format!("could not start the HTTP client: {e}")))?; + Ok(Api { + base, + http, + timeout, + key, + notes, + }) + } + + /// `/api/v3/`, each segment percent-encoded so user input + /// cannot change the path. + fn url(&self, segments: &[&str], query: &[(&str, String)]) -> Url { + let mut url = self.base.clone(); + url.path_segments_mut() + .expect("API base URL has a path") + .pop_if_empty() + .extend(["api", "v3"]) + .extend(segments); + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query); + } + url + } + + /// GETs an endpoint and returns the whole success envelope + /// (`{"status":"success","data":…}`). + pub fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result { + let raw = self.fetch(&self.url(segments, query), "application/json")?; + let body: Value = serde_json::from_slice(&raw.body).map_err(|_| not_api_data(200))?; + if body["status"] != "success" { + return Err(CliError::Other( + api_message(&body).unwrap_or_else(|| "unexpected response from SoChain".into()), + )); + } + Ok(body) + } + + /// GETs an endpoint that answers without the envelope (`/account_info`). + pub fn get_plain(&self, segments: &[&str]) -> Result { + let raw = self.fetch(&self.url(segments, &[]), "application/json")?; + serde_json::from_slice(&raw.body).map_err(|_| not_api_data(200)) + } + + /// GETs an endpoint as CSV, returning the body and the reply's headers + /// (the next page is in `Link` and `X-Next-Cursor`). + pub fn get_csv(&self, segments: &[&str], query: &[(&str, String)]) -> Result { + self.fetch(&self.url(segments, query), "text/csv") + } + + /// GETs a file from the site itself (not the JSON API), such as the + /// agent-skills index. `path` must be an absolute path, or a URL on the + /// same origin; replies larger than `max_bytes` are refused. The API key + /// is not sent. + pub fn fetch_site_file(&self, path: &str, max_bytes: usize) -> Result> { + let refuse = || CliError::Other(format!("refusing to fetch {path:?}: not on sochain.com")); + let url = if path.starts_with('/') && !path.starts_with("//") { + self.base.join(path).map_err(|_| refuse())? + } else { + let url = Url::parse(path).map_err(|_| refuse())?; + if url.origin() != self.base.origin() { + return Err(refuse()); + } + url + }; + let resp = self.send(&url, "*/*", false)?; + let status = resp.status(); + if !status.is_success() { + return Err(match status { + StatusCode::NOT_FOUND => CliError::NotFound(format!("{path} not found")), + s if is_unavailable(s) => CliError::Unavailable(format!( + "SoChain is unavailable right now (HTTP {}); try again later", + s.as_u16() + )), + s => CliError::Other(format!("{path}: HTTP {}", s.as_u16())), + }); + } + let body = resp + .bytes() + .map_err(|e| CliError::Unavailable(format!("could not read {path}: {e}")))?; + if body.len() > max_bytes { + return Err(CliError::Other(format!( + "{path} is larger than expected ({} bytes)", + body.len() + ))); + } + Ok(body.to_vec()) + } + + /// Sends a GET, waiting out short rate limits, and maps failures to + /// errors with the API's own message where it gives one. + fn fetch(&self, url: &Url, accept: &str) -> Result { + let mut attempt = 0; + let resp = loop { + let resp = self.send(url, accept, true)?; + if resp.status() != StatusCode::TOO_MANY_REQUESTS { + break resp; + } + let wait = retry_after(&resp).unwrap_or(1); + attempt += 1; + if attempt > RATE_LIMIT_RETRIES || wait > MAX_RETRY_WAIT_SECS { + let message = resp + .json::() + .ok() + .and_then(|b| api_message(&b)) + .unwrap_or_else(|| "too many requests".into()); + return Err(CliError::RateLimited { + message: format!( + "rate limited by SoChain: {}; try again in {}", + message.trim_end_matches('.'), + seconds(wait) + ), + retry_after_secs: wait, + }); + } + self.note(&format!( + "too many requests at once; retrying in {}", + seconds(wait) + )); + sleep(Duration::from_secs(wait)); + }; + let status = resp.status(); + let headers = resp.headers().clone(); + if let Some(w) = headers.get(WARNING).and_then(|v| v.to_str().ok()) { + self.note(&format!("note from SoChain: {}", warning_text(w))); + } + let body = resp + .bytes() + .map_err(|e| CliError::Unavailable(format!("the reply was cut off: {e}")))? + .to_vec(); + if status.is_success() { + return Ok(Raw { body, headers }); + } + let message = serde_json::from_slice::(&body) + .ok() + .and_then(|b| api_message(&b)); + Err(match status { + StatusCode::BAD_REQUEST => { + CliError::BadInput(message.unwrap_or_else(|| "SoChain rejected the request".into())) + } + StatusCode::NOT_FOUND => { + CliError::NotFound(message.unwrap_or_else(|| "not found".into())) + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + let message = message.unwrap_or_else(|| { + format!("SoChain refused the request (HTTP {})", status.as_u16()) + }); + CliError::Unauthorized(if self.key.is_some() { + message + } else { + format!( + "{message} This needs an API key: run `sochain auth login` or set SOCHAIN_API_KEY." + ) + }) + } + s if is_unavailable(s) => CliError::Unavailable(message.unwrap_or_else(|| { + format!( + "SoChain is unavailable right now (HTTP {}); try again later", + s.as_u16() + ) + })), + s if message.is_none() && !s.is_client_error() => not_api_data(s.as_u16()), + s => CliError::Other( + message.unwrap_or_else(|| format!("SoChain returned HTTP {}", s.as_u16())), + ), + }) + } + + fn send(&self, url: &Url, accept: &str, with_key: bool) -> Result { + let mut req = self + .http + .get(url.clone()) + .timeout(self.timeout) + .header(ACCEPT, accept); + if let (true, Some(key)) = (with_key, &self.key) { + let mut value = HeaderValue::from_str(key).map_err(|_| { + CliError::BadInput("the API key has characters a key can't have".into()) + })?; + value.set_sensitive(true); + req = req.header("API-KEY", value); + } + req.send().map_err(|e| unreachable_error(&e)) + } + + fn note(&self, text: &str) { + if self.notes { + eprintln!("sochain: {}", sanitize::clean(text)); + } + } +} + +/// Tests point the binary at a local server. Only debug builds read this, so +/// released binaries always talk to sochain.com. +fn base_url() -> String { + #[cfg(debug_assertions)] + if let Ok(base) = std::env::var("SOCHAIN_TEST_API_BASE") { + return base; + } + SITE.to_owned() +} + +fn api_message(body: &Value) -> Option { + body["data"]["error_message"].as_str().map(str::to_owned) +} + +/// Something answered, but not the API: e.g. a web page or proxy error in +/// front of it. +fn not_api_data(status: u16) -> CliError { + CliError::Unavailable(format!( + "SoChain did not return API data (HTTP {status}); try again later" + )) +} + +fn is_unavailable(s: StatusCode) -> bool { + matches!( + s, + StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT + ) +} + +/// Seconds from a Retry-After header in its delay form. +fn retry_after(resp: &Response) -> Option { + resp.headers() + .get(RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse() + .ok() +} + +/// `1 second`, `45 seconds`, `3 h 20 min`. +fn seconds(s: u64) -> String { + match s { + 1 => "1 second".into(), + 0..120 => format!("{s} seconds"), + 120..3_600 => format!("{} min", s / 60), + _ => format!("{} h {} min", s / 3_600, s % 3_600 / 60), + } +} + +/// The text of a `Warning: 199 - "text"` header, or the whole value. +fn warning_text(w: &str) -> &str { + match (w.find('"'), w.rfind('"')) { + (Some(a), Some(b)) if b > a => &w[a + 1..b], + _ => w, + } +} + +fn unreachable_error(e: &reqwest::Error) -> CliError { + let what = if e.is_timeout() { + "timed out waiting for SoChain".to_owned() + } else { + "could not reach SoChain".to_owned() + }; + // The innermost cause is the useful part ("connection refused", DNS, TLS). + let mut cause: &dyn std::error::Error = e; + while let Some(next) = cause.source() { + cause = next; + } + CliError::Unavailable(format!("{what}: {cause}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_waits() { + assert_eq!(seconds(1), "1 second"); + assert_eq!(seconds(30), "30 seconds"); + assert_eq!(seconds(600), "10 min"); + assert_eq!(seconds(12_000), "3 h 20 min"); + } + + #[test] + fn reads_warning_headers() { + assert_eq!( + warning_text(r#"199 - "data is 2 blocks behind""#), + "data is 2 blocks behind" + ); + assert_eq!(warning_text("plain"), "plain"); + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..538009b --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,264 @@ +//! The SoChain API key: where it comes from, and `sochain auth`. +//! +//! The key is read from SOCHAIN_API_KEY or, failing that, from a file saved +//! by `sochain auth login` (readable only by you). It is sent only to +//! sochain.com, in the `API-KEY` header, and never printed. + +use std::fs; +use std::io::{self, BufRead, IsTerminal, Write}; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::api::Api; +use crate::error::{CliError, Result}; + +pub const ENV_KEY: &str = "SOCHAIN_API_KEY"; +const FILE_NAME: &str = "credentials.json"; +const MAX_KEY_LEN: usize = 256; + +#[derive(Debug, Clone)] +pub enum Source { + Env, + File(PathBuf), +} + +impl Source { + fn describe(&self) -> String { + match self { + Source::Env => format!("from {ENV_KEY}"), + Source::File(p) => format!("saved in {}", p.display()), + } + } +} + +#[derive(Serialize, Deserialize)] +struct Credentials { + api_key: String, +} + +/// The key to use, if any: SOCHAIN_API_KEY first, then the saved key. +pub fn resolve() -> Result> { + if let Ok(key) = std::env::var(ENV_KEY) { + let key = key.trim().to_owned(); + if !key.is_empty() { + check_shape(&key)?; + return Ok(Some((key, Source::Env))); + } + } + let Some(path) = credentials_path() else { + return Ok(None); + }; + let Ok(text) = fs::read(&path) else { + return Ok(None); + }; + let creds: Credentials = serde_json::from_slice(&text).map_err(|_| { + CliError::Other(format!( + "{} is not a saved key; run `sochain auth logout` and log in again", + path.display() + )) + })?; + check_shape(&creds.api_key)?; + Ok(Some((creds.api_key, Source::File(path)))) +} + +/// `$SOCHAIN_CONFIG_DIR`, else the platform's config folder plus `sochain`: +/// `$XDG_CONFIG_HOME/sochain` or `~/.config/sochain`, `%APPDATA%\sochain` on +/// Windows. +pub fn config_dir() -> Option { + let from_env = |name: &str| { + std::env::var_os(name) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + }; + if let Some(dir) = from_env("SOCHAIN_CONFIG_DIR") { + return Some(dir); + } + let base = if cfg!(windows) { + from_env("APPDATA") + } else { + from_env("XDG_CONFIG_HOME").or_else(|| std::env::home_dir().map(|h| h.join(".config"))) + }; + base.map(|b| b.join("sochain")) +} + +fn credentials_path() -> Option { + config_dir().map(|d| d.join(FILE_NAME)) +} + +/// Keys are short runs of visible ASCII. Anything else is a paste mistake, +/// and would not survive as an HTTP header anyway. +fn check_shape(key: &str) -> Result<()> { + if key.is_empty() || key.len() > MAX_KEY_LEN || !key.bytes().all(|b| b.is_ascii_graphic()) { + return Err(CliError::BadInput( + "that doesn't look like a SoChain API key (one line, no spaces)".into(), + )); + } + Ok(()) +} + +/// Reads the key: hidden at a prompt on a terminal, else one line of stdin. +fn read_key() -> Result { + let key = if io::stdin().is_terminal() { + eprintln!("Paste your SoChain API key (from https://sochain.com/dashboard)."); + rpassword::prompt_password("API key: ") + .map_err(|e| CliError::Other(format!("could not read the key: {e}")))? + } else { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| CliError::Other(format!("could not read the key: {e}")))?; + line + }; + let key = key.trim().to_owned(); + check_shape(&key)?; + Ok(key) +} + +pub fn login( + make_api: impl Fn(Option) -> Result, + verify: bool, + json: bool, + out: &mut dyn Write, +) -> Result<()> { + let key = read_key()?; + let account = if verify { + Some(make_api(Some(key.clone()))?.get_plain(&["account_info"])?) + } else { + None + }; + let path = credentials_path() + .ok_or_else(|| CliError::Other("could not find a folder to save the key in".into()))?; + save(&path, &key)?; + let plan = account + .as_ref() + .and_then(|a| a["plan"]["name"].as_str().map(str::to_owned)); + let key_name = account + .as_ref() + .and_then(|a| a["key"]["name"].as_str().map(str::to_owned)); + if json { + let data = + json!({ "saved_to": path, "verified": verify, "plan": plan, "key_name": key_name }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + return Ok(()); + } + match (plan, key_name) { + (Some(plan), Some(name)) => { + writeln!(out, "Logged in with the key \"{name}\" ({plan} plan).")? + } + (Some(plan), None) => writeln!(out, "Logged in ({plan} plan).")?, + _ => writeln!(out, "Saved the key without checking it.")?, + } + writeln!(out, "Saved to {} (readable only by you).", path.display())?; + if std::env::var(ENV_KEY).is_ok_and(|v| !v.trim().is_empty()) { + writeln!( + out, + "Note: {ENV_KEY} is set, and takes precedence over the saved key." + )?; + } + Ok(()) +} + +fn save(path: &PathBuf, key: &str) -> Result<()> { + let fail = |e: io::Error| CliError::Other(format!("could not save {}: {e}", path.display())); + let dir = path.parent().expect("credentials path has a folder"); + fs::create_dir_all(dir).map_err(fail)?; + let body = serde_json::to_vec_pretty(&Credentials { + api_key: key.to_owned(), + }) + .expect("credentials serialize"); + // Write to a temporary file with owner-only permissions, then move it + // into place, so the key is never readable by others even briefly. + let tmp = dir.join(format!(".{FILE_NAME}.tmp")); + let _ = fs::remove_file(&tmp); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + options.mode(0o600); + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } + let mut file = options.open(&tmp).map_err(fail)?; + file.write_all(&body).map_err(fail)?; + file.sync_all().map_err(fail)?; + drop(file); + fs::rename(&tmp, path).map_err(fail) +} + +pub fn logout(json: bool, out: &mut dyn Write) -> Result<()> { + let path = credentials_path() + .ok_or_else(|| CliError::Other("could not find the config folder".into()))?; + let removed = match fs::remove_file(&path) { + Ok(()) => true, + Err(e) if e.kind() == io::ErrorKind::NotFound => false, + Err(e) => { + return Err(CliError::Other(format!( + "could not remove {}: {e}", + path.display() + ))); + } + }; + let env_set = std::env::var(ENV_KEY).is_ok_and(|v| !v.trim().is_empty()); + if json { + let data = json!({ "removed": removed, "path": path, "env_key_set": env_set }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + return Ok(()); + } + if removed { + writeln!(out, "Removed the saved key ({}).", path.display())?; + } else { + writeln!(out, "No key was saved.")?; + } + if env_set { + writeln!(out, "{ENV_KEY} is still set in your environment.")?; + } + Ok(()) +} + +pub fn status( + key: Option<(String, Source)>, + make_api: impl Fn(Option) -> Result, + json: bool, + out: &mut dyn Write, +) -> Result<()> { + let Some((key, source)) = key else { + if json { + let data = json!({ "key": null }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + } else { + writeln!( + out, + "No API key. Lookups still work, up to 2,500 a day per IP address.\n\ + Get a key at https://sochain.com/signin, then run `sochain auth login` \ + or set {ENV_KEY}." + )?; + } + return Ok(()); + }; + let account = make_api(Some(key))?.get_plain(&["account_info"])?; + if json { + let source = match &source { + Source::Env => json!({ "kind": "env", "name": ENV_KEY }), + Source::File(p) => json!({ "kind": "file", "path": p }), + }; + let data = json!({ "key": { "source": source }, "account": account }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + return Ok(()); + } + let name = account["key"]["name"] + .as_str() + .map(|n| format!(" \"{n}\"")) + .unwrap_or_default(); + let access = match account["key"]["access"].as_str() { + Some("read_only") => ", read-only", + _ => "", + }; + writeln!(out, "Using the key{name}{access}, {}.", source.describe())?; + if let Some(plan) = account["plan"]["name"].as_str() { + writeln!(out, "Plan: {plan}. Run `sochain account` for usage.")?; + } + Ok(()) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..0cd9db5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,573 @@ +//! Command-line surface. Command names, flag names and environment variable +//! names are public interface: renaming or removing any of them is a +//! breaking change. + +use clap::builder::FalseyValueParser; +use clap::{ArgAction, ArgGroup, Parser, Subcommand, ValueEnum}; + +use crate::network::Network; + +#[derive(Debug, Parser)] +#[command( + name = "sochain", + version, + about = "Bitcoin, Litecoin, Dogecoin and Zcash blockchain data from your terminal, via SoChain", + long_about = "Bitcoin, Litecoin, Dogecoin and Zcash blockchain data from your terminal, via SoChain.\n\n\ + Every lookup names its network first: BTC, LTC, DOGE, ZEC, or a testnet (BTCTEST, \ + LTCTEST, DOGETEST, ZECTEST), in any case.\n\n\ + Output is plain English by default. Pass --json (or set SOCHAIN_JSON=1) for the \ + API's JSON reply, unchanged; errors are then JSON on stderr.\n\n\ + Lookups work without an API key (2,500 a day per IP address). With a key, your \ + plan's limits apply: run `sochain auth login`, or set SOCHAIN_API_KEY.\n\n\ + Exit codes: 0 ok, 1 other error, 2 bad input, 3 not found, 4 SoChain unavailable, \ + 5 rate limited, 6 API key missing, invalid or not allowed.", + after_help = "Examples:\n \ + sochain balance BTC bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh\n \ + sochain transaction LTC \n \ + sochain block DOGE latest\n \ + sochain fees BTC\n \ + sochain transactions BTC
--from-time 2025-01-01 --direction in --json\n\n\ + Run `sochain guide` for a guide aimed at scripts and AI agents.\n\n\ + Data comes from SoChain and is subject to its Terms of Service: https://sochain.com/terms" +)] +pub struct Cli { + /// Print the API's JSON reply instead of text (errors as JSON on stderr) + #[arg( + long, + global = true, + env = "SOCHAIN_JSON", + action = ArgAction::SetTrue, + value_parser = FalseyValueParser::new() + )] + pub json: bool, + + /// Give up on a request after this many seconds + #[arg( + long, + global = true, + env = "SOCHAIN_TIMEOUT", + default_value_t = 30, + value_name = "SECS" + )] + pub timeout: u64, + + /// Show prices in this currency instead of US dollars (EUR, GBP, JPY, …) + #[arg( + long, + global = true, + env = "SOCHAIN_CURRENCY", + value_name = "CODE", + value_parser = parse_currency + )] + pub currency: Option, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Address balance: confirmed and unconfirmed, or the confirmed balance at a past block or time + #[command( + after_help = "Examples:\n sochain balance BTC bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh\n \ + sochain balance DOGE
--time 2025-12-31\n sochain balance LTC
--height 2800000" + )] + #[command(group(ArgGroup::new("at").args(["height", "time"])))] + Balance { + #[arg(ignore_case = true)] + network: Network, + address: String, + + /// The confirmed balance at the end of this block + #[arg(long, value_name = "N")] + height: Option, + + /// The confirmed balance at this time: unix seconds, a UTC date (the + /// end of that day) or an RFC 3339 timestamp + #[arg(long, value_name = "TIME")] + time: Option, + }, + + /// Address summary: balance, totals received, transaction counts, first and last activity + #[command(visible_alias = "address")] + AddressSummary { + #[arg(ignore_case = true)] + network: Network, + address: String, + }, + + /// Address transactions: 10 per page, newest first, or filtered by time, height and direction + #[command( + visible_alias = "txs", + after_help = "Without filters, pages are numbered (--page). With any filter, pages are \ + linked by a cursor: pass the printed --cursor to get the next one.\n\n\ + Examples:\n sochain transactions BTC
\n sochain transactions BTC
--page 2\n \ + sochain transactions LTC
--from-time 2025-01-01 --to-time 2025-12-31 --direction in\n \ + sochain transactions DOGE
--from-time 2025-01-01 --csv > history.csv" + )] + Transactions { + #[arg(ignore_case = true)] + network: Network, + address: String, + + #[command(flatten)] + filters: HistoryFilters, + }, + + /// Address transaction counts: sent, received and total + #[command(visible_alias = "counts")] + TransactionCounts { + #[arg(ignore_case = true)] + network: Network, + address: String, + }, + + /// Address unspent outputs: spendable outputs, 10 per page, oldest first + #[command(visible_aliases = ["utxos", "unspent"])] + UnspentOutputs { + #[arg(ignore_case = true)] + network: Network, + address: String, + + /// Page to show, starting at 1 + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=999_999))] + page: u32, + }, + + /// Is valid address: whether an address is valid on a network + #[command(visible_alias = "validate")] + IsValidAddress { + #[arg(ignore_case = true)] + network: Network, + address: String, + }, + + /// Transaction: confirmations, fee, inputs and outputs + #[command(visible_alias = "tx")] + Transaction { + #[arg(ignore_case = true)] + network: Network, + /// Transaction id (64 hex characters) + hash: String, + }, + + /// Transaction status: pending, mined, replaced, dropped, expired or unknown + #[command(visible_alias = "status")] + TransactionStatus { + #[arg(ignore_case = true)] + network: Network, + /// Transaction id (64 hex characters) + hash: String, + }, + + /// Block: header, reward, fees and transaction ids + #[command( + after_help = "Examples:\n sochain block BTC latest\n sochain block LTC 2800000\n sochain block DOGE --json" + )] + Block { + #[arg(ignore_case = true)] + network: Network, + /// Block height, block hash, or `latest` + id: String, + }, + + /// Latest blocks: the 10 newest, with miner and reward + #[command(visible_alias = "blocks")] + LatestBlocks { + #[arg(ignore_case = true)] + network: Network, + }, + + /// Best block hash: the hash of the newest block + BestBlockHash { + #[arg(ignore_case = true)] + network: Network, + }, + + /// Network info: block count, hashrate, the mempool and projected blocks + #[command(visible_alias = "network")] + NetworkInfo { + #[arg(ignore_case = true)] + network: Network, + }, + + /// Fee rates for the next blocks, from the mempool + Fees { + #[arg(ignore_case = true)] + network: Network, + }, + + /// Price: the latest, or a UTC day's opening price + #[command( + after_help = "Examples:\n sochain price BTC\n sochain price LTC --time 2024-01-01\n sochain price DOGE --currency EUR" + )] + Price { + #[arg(ignore_case = true)] + network: Network, + + /// That UTC day's opening price: unix seconds or a date (YYYY-MM-DD) + #[arg(long, value_name = "TIME")] + time: Option, + }, + + /// Rich list: the top 1,000 holders + #[command(visible_aliases = ["richlist", "top"])] + RichList { + #[arg(ignore_case = true)] + network: Network, + + /// Page to show, from 1 + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=40))] + page: u32, + + /// Holders per page: 25, 50 or 100 + #[arg(long, default_value_t = 100, value_parser = parse_page_size)] + page_size: u32, + }, + + /// Address rank among the largest holders, and its share of the supply + #[command(visible_alias = "rank")] + AddressRank { + #[arg(ignore_case = true)] + network: Network, + address: String, + }, + + /// Balance history: confirmed balance per day, week or month, with amounts in and out + #[command( + after_help = "Examples:\n sochain balance-history BTC
--interval month\n \ + sochain balance-history DOGE
--from 2025-01-01 --to 2025-06-30 --json" + )] + BalanceHistory { + #[arg(ignore_case = true)] + network: Network, + address: String, + + /// One point per day, week (Monday to Sunday, UTC) or month + #[arg(long, value_enum, default_value_t = HistoryInterval::Day)] + interval: HistoryInterval, + + /// Start: unix seconds, a UTC date or an RFC 3339 timestamp + #[arg(long, value_name = "TIME")] + from: Option, + + /// End, in the same forms (a date means its last second) + #[arg(long, value_name = "TIME")] + to: Option, + + /// Text output shows only the most recent N points (0 for all). + /// `--json` always returns every point. + #[arg(long, value_name = "N", default_value_t = 30)] + last: usize, + }, + + /// Address poisoning: look-alike addresses that sent dust imitating a real counterparty + #[command(visible_alias = "poisoning")] + AddressPoisoning { + #[arg(ignore_case = true)] + network: Network, + address: String, + + /// Rows to show, 1 to 100, newest first + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, + + /// Address counterparties: who it paid and was paid by lately + #[command(visible_alias = "counterparties")] + AddressCounterparties { + #[arg(ignore_case = true)] + network: Network, + address: String, + + /// Rows to show, 1 to 100, newest first + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, + + /// Chart: a series as a table of values + #[command( + after_help = "Series: tx_count, tx_per_min, fees_total, fees_median, block_size, block_interval, \ + difficulty, hashrate, supply, price_usd, pool_share, active_addresses, new_addresses, \ + transfers, mempool_txs, mempool_bytes, holder_share, lookalikes; on LTC mweb_pool and \ + mweb_flows; on ZEC zcash_pools. Live reference: https://sochain.com/docs/charts/series\n\n\ + Intervals: block (last 1,000 blocks; --from/--to are heights), hour (last 90 days), \ + day (whole chain), minute (last 60 minutes; tx_per_min, mempool_txs, mempool_bytes).\n\n\ + Examples:\n sochain chart BTC tx_count\n sochain chart LTC hashrate --interval hour --last 48\n \ + sochain chart DOGE tx_per_min --interval minute --json" + )] + Chart { + #[arg(ignore_case = true)] + network: Network, + + /// Series name, such as tx_count, hashrate or price_usd + series: String, + + /// Time bucket for each point + #[arg(long, value_enum, default_value_t = ChartInterval::Day)] + interval: ChartInterval, + + /// Start: a block height for --interval block, else unix seconds + #[arg(long, value_name = "N")] + from: Option, + + /// End, in the same units + #[arg(long, value_name = "N")] + to: Option, + + /// Text output shows only the most recent N points (0 for all). + /// `--json` always returns every point. + #[arg(long, value_name = "N", default_value_t = 30)] + last: usize, + }, + + /// Account info: your plan and requests used (needs an API key) + Account, + + /// Log in with an API key, log out, or show which key is used + #[command(subcommand)] + Auth(AuthCommand), + + /// Install the SoChain skill for AI agents (Claude Code, Codex) + #[command(subcommand)] + Skill(SkillCommand), + + /// Add the SoChain MCP server to AI agents (Codex, Claude Code) + #[command(subcommand)] + Mcp(McpCommand), + + /// Print every command with its API endpoint and output shape, as JSON + Schema, + + /// Print a usage guide for scripts and AI agents + Guide, +} + +#[derive(Debug, clap::Args)] +pub struct HistoryFilters { + /// Page to show, starting at 1 (not with filters) + #[arg(long, value_parser = clap::value_parser!(u32).range(1..=999_999), conflicts_with_all = ["from_height", "to_height", "from_time", "to_time", "direction", "unconfirmed", "page_size", "cursor", "csv"])] + pub page: Option, + + /// Filter: from this block on (inclusive) + #[arg(long, value_name = "N")] + pub from_height: Option, + + /// Filter: up to this block (inclusive) + #[arg(long, value_name = "N")] + pub to_height: Option, + + /// Filter: from this time on: unix seconds, a UTC date or an RFC 3339 timestamp + #[arg(long, value_name = "TIME")] + pub from_time: Option, + + /// Filter: up to this time (a date means its last second) + #[arg(long, value_name = "TIME")] + pub to_time: Option, + + /// Filter: in (the balance went up), out (it went down) or both + #[arg(long, value_enum)] + pub direction: Option, + + /// Filter: pending transactions (default include, or exclude when the + /// range has an end) + #[arg(long, value_enum)] + pub unconfirmed: Option, + + /// Rows per page with filters: 1 to 100 (default 10), or up to 500 with --csv + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=500))] + pub page_size: Option, + + /// The next page, as printed after the previous one (carries the filters) + #[arg(long, value_name = "CURSOR")] + pub cursor: Option, + + /// Print CSV (time_utc, block, txid, direction, received, sent, net, price, net value) + #[arg(long)] + pub csv: bool, +} + +impl HistoryFilters { + /// Whether the filtered (cursor-paged) form of the endpoint is wanted. + pub fn filtered(&self) -> bool { + self.from_height.is_some() + || self.to_height.is_some() + || self.from_time.is_some() + || self.to_time.is_some() + || self.direction.is_some() + || self.unconfirmed.is_some() + || self.page_size.is_some() + || self.cursor.is_some() + || self.csv + } +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Direction { + In, + Out, + Both, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Unconfirmed { + Include, + Exclude, + Only, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum HistoryInterval { + Day, + Week, + Month, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ChartInterval { + Block, + Minute, + Hour, + Day, +} + +/// The name a value enum has on the command line, which is also its API value. +pub fn value_name(v: &T) -> String { + v.to_possible_value() + .expect("no skipped values") + .get_name() + .to_owned() +} + +fn parse_currency(s: &str) -> Result { + if s.len() == 3 && s.bytes().all(|b| b.is_ascii_alphabetic()) { + Ok(s.to_ascii_uppercase()) + } else { + Err("expected a three-letter currency code, such as EUR".into()) + } +} + +fn parse_page_size(s: &str) -> Result { + match s.parse() { + Ok(n @ (25 | 50 | 100)) => Ok(n), + _ => Err("expected 25, 50 or 100".into()), + } +} + +#[derive(Debug, Subcommand)] +pub enum AuthCommand { + /// Save an API key (read from a hidden prompt, or from stdin) + #[command( + after_help = "Get a key at https://sochain.com/signin; it is shown once, on the dashboard.\n\ + The key is checked with SoChain, then saved in your config folder, readable\n\ + only by you. SOCHAIN_API_KEY, when set, takes precedence over it.\n\n\ + Examples:\n sochain auth login\n printenv MY_SOCHAIN_KEY | sochain auth login" + )] + Login { + /// Save the key without checking it with SoChain first + #[arg(long)] + no_verify: bool, + }, + + /// Remove the saved API key + Logout, + + /// Show which API key is used, and its plan + Status, +} + +#[derive(Debug, Subcommand)] +pub enum SkillCommand { + /// Download the skill from sochain.com, verify it and install it + #[command( + after_help = "Installs into ~/.claude/skills (Claude Code) and ~/.agents/skills (Codex) for\n\ + each agent found. The skill is fetched from sochain.com and checked against\n\ + its published SHA-256 digest before anything is written.\n\n\ + Examples:\n sochain skill install\n sochain skill install --project\n \ + sochain skill install --dir ~/my-agent/skills\n sochain skill install --print" + )] + Install { + #[command(flatten)] + location: SkillLocation, + + /// Print the verified SKILL.md instead of installing it + #[arg(long)] + print: bool, + + /// Replace copies that were changed since they were installed + #[arg(long)] + force: bool, + }, + + /// Show where the skill is installed and whether it is up to date + Status { + #[command(flatten)] + location: SkillLocation, + }, + + /// Remove the installed skill + Uninstall { + #[command(flatten)] + location: SkillLocation, + + /// Also remove copies that were changed since they were installed + #[arg(long)] + force: bool, + }, +} + +#[derive(Debug, clap::Args)] +pub struct SkillLocation { + /// Use the agents' folders in the current project (.claude/skills and + /// .agents/skills) instead of your home directory + #[arg(long, conflicts_with = "dir")] + pub project: bool, + + /// Use this skills folder instead; the skill goes in a folder inside it + #[arg(long, value_name = "DIR")] + pub dir: Option, +} + +#[derive(Debug, Subcommand)] +pub enum McpCommand { + /// Add https://sochain.com/mcp to Codex and Claude Code + #[command( + after_help = "Uses each agent's own command (`codex mcp add`, `claude mcp add`); nothing\n\ + runs locally. Without --use-api-key the server is used without a key (2,500\n\ + lookups a day per IP address). With it, the agent reads your key from\n\ + SOCHAIN_API_KEY when it starts, so the key itself is not written to its config.\n\ + For Cursor and other apps, --print shows what to add.\n\n\ + Examples:\n sochain mcp install\n sochain mcp install --use-api-key\n sochain mcp install --print" + )] + Install { + /// Have the agent send the key in SOCHAIN_API_KEY (set it where the agent runs) + #[arg(long)] + use_api_key: bool, + + /// Add it to the current project only (Claude Code's project scope) + #[arg(long)] + project: bool, + + /// Show the setup for every supported app instead of changing anything + #[arg(long)] + print: bool, + + /// Replace a server named `sochain` that is set up differently + #[arg(long)] + force: bool, + }, + + /// Show which agents have the SoChain MCP server + Status, + + /// Remove the SoChain MCP server from Codex and Claude Code + Uninstall { + /// Remove it from the current project only (Claude Code) + #[arg(long)] + project: bool, + + /// Also remove a server named `sochain` that points somewhere else + #[arg(long)] + force: bool, + }, +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..c51bb43 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,1407 @@ +//! Runs each command: one API call (two for `block latest`), then either the +//! API's JSON reply unchanged (`--json`) or plain-English text. +//! +//! House rules for text output: addresses, transaction ids and hashes are +//! always printed in full, never shortened; amounts are in the network's own +//! coin. + +use std::io::Write; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +use crate::amount::{Amount, group_thousands}; +use crate::api::Api; +use crate::auth; +use crate::cli::{ + AuthCommand, Cli, Command, HistoryFilters, McpCommand, SkillCommand, SkillLocation, value_name, +}; +use crate::error::{CliError, Result}; +use crate::mcp; +use crate::model::*; +use crate::network::Network; +use crate::secret; +use crate::skill::{self, Scope}; +use crate::time::{ago, now, parse_unix_or_date, utc, utc_date, utc_minute}; + +const PAGE_SIZE: usize = 10; + +pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { + // Nothing that looks like a private key or recovery phrase is ever sent. + for input in lookup_inputs(&cli.command) { + secret::refuse_secrets(input)?; + } + let json = cli.json; + let timeout = Duration::from_secs(cli.timeout); + let make_api = |key: Option| Api::new(timeout, key, !json); + // Lookups use the key when there is one; they work without it too. + let api = || make_api(auth::resolve()?.map(|(key, _)| key)); + let currency = cli.currency.as_deref(); + let cur = || -> Vec<(&str, String)> { + currency + .map(|c| vec![("currency", c.to_owned())]) + .unwrap_or_default() + }; + let lookup = |segments: &[&str], query: &[(&str, String)]| -> Result { + api()?.get(segments, query) + }; + + match cli.command { + Command::Balance { + network, + address, + height, + time, + } => { + let mut query = vec![]; + if let Some(h) = height { + query.push(("height", h.to_string())); + } + if let Some(t) = time { + query.push(("time", t)); + } + let env = lookup(&["balance", network.code(), &address], &query)?; + emit(out, json, &env, |out, d| { + show_balance(out, network, &address, d) + }) + } + Command::AddressSummary { network, address } => { + let env = lookup(&["address_summary", network.code(), &address], &[])?; + emit(out, json, &env, |out, d| { + show_summary(out, network, &address, d) + }) + } + Command::Transactions { + network, + address, + filters, + } => transactions(&api()?, network, &address, &filters, currency, json, out), + Command::TransactionCounts { network, address } => { + let env = lookup(&["transaction_counts", network.code(), &address], &[])?; + emit(out, json, &env, |out, c: TransactionCounts| { + writeln!( + out, + "{}: {} received, {} sent", + plural(c.total, "transaction"), + group_thousands(c.received.into()), + group_thousands(c.sent.into()) + )?; + Ok(()) + }) + } + Command::UnspentOutputs { + network, + address, + page, + } => { + let env = lookup( + &[ + "unspent_outputs", + network.code(), + &address, + &page.to_string(), + ], + &[], + )?; + emit(out, json, &env, |out, d| { + show_unspent(out, network, &address, page, d) + }) + } + Command::IsValidAddress { network, address } => { + let env = lookup(&["is_valid_address", network.code(), &address], &[])?; + emit(out, json, &env, |out, v: IsValid| { + if v.is_valid { + writeln!(out, "Valid {} address.", network.name())?; + } else { + writeln!(out, "Not a valid {} address.", network.name())?; + } + Ok(()) + }) + } + Command::Transaction { network, hash } => { + let env = lookup(&["transaction", network.code(), &hash], &cur())?; + emit(out, json, &env, |out, d| show_transaction(out, network, d)) + } + Command::TransactionStatus { network, hash } => { + let env = lookup(&["transaction_status", network.code(), &hash], &[])?; + emit(out, json, &env, |out, d| show_status(out, &hash, d)) + } + Command::Block { network, id } => { + let api = api()?; + let id = if id.eq_ignore_ascii_case("latest") { + let env = api.get(&["best_block_hash", network.code()], &[])?; + data::(&env)?.hash + } else { + id + }; + let env = api.get(&["block", network.code(), &id], &cur())?; + emit(out, json, &env, |out, d| show_block(out, network, d)) + } + Command::LatestBlocks { network } => { + let env = lookup(&["latest_blocks_summary", network.code()], &cur())?; + emit(out, json, &env, |out, d| { + show_latest_blocks(out, network, d) + }) + } + Command::BestBlockHash { network } => { + let env = lookup(&["best_block_hash", network.code()], &[])?; + emit(out, json, &env, |out, b: BestBlockHash| { + writeln!(out, "{}", b.hash)?; + Ok(()) + }) + } + Command::NetworkInfo { network } => { + let env = lookup(&["network_info", network.code()], &[])?; + emit(out, json, &env, |out, d| show_network_info(out, network, d)) + } + Command::Fees { network } => { + let env = lookup(&["network_info", network.code()], &[])?; + if json { + return print_json( + out, + &json!({ "status": "success", "data": fees_json(network, &env["data"]) }), + ); + } + show_fees(out, network, &data(&env)?) + } + Command::Price { network, time } => { + let mut segments = vec!["price".to_owned(), network.code().to_owned()]; + if let Some(t) = &time { + let ts = parse_unix_or_date(t).ok_or_else(|| { + CliError::BadInput(format!( + "invalid --time {t:?}: expected unix seconds or a date (YYYY-MM-DD)" + )) + })?; + segments.push(ts.to_string()); + } + let segments: Vec<&str> = segments.iter().map(String::as_str).collect(); + let env = lookup(&segments, &cur())?; + emit(out, json, &env, |out, d| { + show_price(out, network, time.is_some(), d) + }) + } + Command::RichList { + network, + page, + page_size, + } => { + let env = lookup( + &["rich_list", network.code()], + &[ + ("page", page.to_string()), + ("page_size", page_size.to_string()), + ], + )?; + emit(out, json, &env, |out, d| show_rich_list(out, network, d)) + } + Command::AddressRank { network, address } => { + let env = lookup(&["address_rank", network.code(), &address], &[])?; + emit(out, json, &env, |out, d| show_rank(out, network, d)) + } + Command::BalanceHistory { + network, + address, + interval, + from, + to, + last, + } => { + let mut query = vec![("interval", value_name(&interval))]; + if let Some(f) = from { + query.push(("from", f)); + } + if let Some(t) = to { + query.push(("to", t)); + } + let env = lookup(&["balance_history", network.code(), &address], &query)?; + emit(out, json, &env, |out, d| { + show_balance_history(out, network, &address, d, last) + }) + } + Command::AddressPoisoning { + network, + address, + limit, + } => { + let env = lookup( + &["address_poisoning", network.code(), &address], + &[("limit", limit.to_string())], + )?; + emit(out, json, &env, |out, d| show_poisoning(out, network, d)) + } + Command::AddressCounterparties { + network, + address, + limit, + } => { + let env = lookup( + &["address_counterparties", network.code(), &address], + &[("limit", limit.to_string())], + )?; + emit(out, json, &env, |out, d| { + show_counterparties(out, network, d) + }) + } + Command::Chart { + network, + series, + interval, + from, + to, + last, + } => { + let mut query = cur(); + if let Some(f) = from { + query.push(("from", f.to_string())); + } + if let Some(t) = to { + query.push(("to", t.to_string())); + } + let interval = value_name(&interval); + let env = lookup(&["charts", network.code(), &series, &interval], &query)?; + emit(out, json, &env, |out, d| show_chart(out, network, d, last)) + } + Command::Account => { + let Some((key, _)) = auth::resolve()? else { + return Err(CliError::Unauthorized( + "`sochain account` needs an API key: run `sochain auth login` or set SOCHAIN_API_KEY" + .into(), + )); + }; + let account = make_api(Some(key))?.get_plain(&["account_info"])?; + if json { + return print_json(out, &account); + } + show_account(out, &account) + } + Command::Auth(cmd) => match cmd { + AuthCommand::Login { no_verify } => auth::login(make_api, !no_verify, json, out), + AuthCommand::Logout => auth::logout(json, out), + AuthCommand::Status => auth::status(auth::resolve()?, make_api, json, out), + }, + Command::Skill(cmd) => { + // The skill comes from the public site; no key is sent for it. + let site = || make_api(None); + match cmd { + SkillCommand::Install { print: true, .. } => { + let s = skill::fetch(&site()?)?; + if json { + let text = String::from_utf8_lossy(&s.bytes); + print_json( + out, + &json!({ "status": "success", "data": { "name": s.name, "digest": s.digest, "content": text } }), + ) + } else { + Ok(out.write_all(&s.bytes)?) + } + } + SkillCommand::Install { + location, force, .. + } => skill::install(&site()?, &scope(location), force, json, out), + SkillCommand::Status { location } => { + skill::status(&site()?, &scope(location), json, out) + } + SkillCommand::Uninstall { location, force } => { + skill::uninstall(&scope(location), force, json, out) + } + } + } + Command::Mcp(cmd) => match cmd { + McpCommand::Install { + project, + use_api_key, + print: true, + .. + } => mcp::print(project, use_api_key, json, out), + McpCommand::Install { + project, + use_api_key, + force, + .. + } => { + let auth = if use_api_key { + let (key, _) = auth::resolve()?.ok_or_else(|| { + CliError::Unauthorized( + "--use-api-key needs a key: run `sochain auth login` or set SOCHAIN_API_KEY" + .into(), + ) + })?; + mcp::Auth::Key(key) + } else { + mcp::Auth::None + }; + mcp::install(project, auth, force, json, out) + } + McpCommand::Status => mcp::status(json, out), + McpCommand::Uninstall { project, force } => mcp::uninstall(project, force, json, out), + }, + Command::Schema => print_json(out, &crate::schema::schema()), + Command::Guide => Ok(out.write_all(crate::GUIDE.as_bytes())?), + } +} + +/// The user-supplied identifiers a command sends to the API. +fn lookup_inputs(command: &Command) -> Vec<&str> { + match command { + Command::Balance { address, .. } + | Command::AddressSummary { address, .. } + | Command::Transactions { address, .. } + | Command::TransactionCounts { address, .. } + | Command::UnspentOutputs { address, .. } + | Command::IsValidAddress { address, .. } + | Command::AddressRank { address, .. } + | Command::BalanceHistory { address, .. } + | Command::AddressPoisoning { address, .. } + | Command::AddressCounterparties { address, .. } => vec![address], + Command::Transaction { hash, .. } | Command::TransactionStatus { hash, .. } => vec![hash], + Command::Block { id, .. } => vec![id], + Command::Chart { series, .. } => vec![series], + _ => vec![], + } +} + +fn scope(location: SkillLocation) -> Scope { + match (location.project, location.dir) { + (_, Some(dir)) => Scope::Dir(dir), + (true, None) => Scope::Project, + (false, None) => Scope::User, + } +} + +fn data(envelope: &Value) -> Result { + serde_json::from_value(envelope["data"].clone()) + .map_err(|e| CliError::Other(format!("unexpected response from SoChain: {e}"))) +} + +/// Prints the envelope unchanged with --json, else reads its data and shows it. +fn emit( + out: &mut dyn Write, + json: bool, + envelope: &Value, + show: impl FnOnce(&mut dyn Write, T) -> Result<()>, +) -> Result<()> { + if json { + return print_json(out, envelope); + } + show(out, data(envelope)?) +} + +fn print_json(out: &mut dyn Write, v: &Value) -> Result<()> { + serde_json::to_writer_pretty(&mut *out, v) + .map_err(|e| CliError::Other(format!("could not write output: {e}")))?; + writeln!(out)?; + Ok(()) +} + +fn show_balance(out: &mut dyn Write, n: Network, address: &str, b: Balance) -> Result<()> { + if let Some(height) = b.height { + let when = b.time.map(|t| format!(", {}", utc(t))).unwrap_or_default(); + writeln!( + out, + "{address} held {} {n} at block {}{when}", + b.confirmed, + group_thousands(height.into()) + )?; + return Ok(()); + } + writeln!(out, "Balance {} {n}", b.confirmed)?; + if let Some(u) = b.unconfirmed.filter(|u| !u.is_zero()) { + writeln!(out, "Pending {u} {n}, not yet confirmed")?; + } + Ok(()) +} + +fn show_summary(out: &mut dyn Write, n: Network, address: &str, s: AddressSummary) -> Result<()> { + writeln!(out, "{address} on {}", n.name())?; + writeln!(out, "Balance {} {n}", s.confirmed_balance)?; + if !s.unconfirmed_balance.is_zero() { + writeln!( + out, + "Pending {} {n}, not yet confirmed", + s.unconfirmed_balance + )?; + } + writeln!( + out, + "Received {} {n} in total · {} ({} received, {} sent)", + s.confirmed_received, + plural(s.txs_total, "transaction"), + group_thousands(s.txs_received.into()), + group_thousands(s.txs_sent.into()) + )?; + if let (Some(first), Some(last)) = (&s.first_active, &s.last_active) { + writeln!( + out, + "First active {} (block {}) · last active {} (block {})", + utc_date(first.time), + group_thousands(first.height.into()), + utc_date(last.time), + group_thousands(last.height.into()) + )?; + } + if s.txs_total > 0 { + writeln!(out, "History: sochain transactions {n} {address}")?; + } + Ok(()) +} + +fn transactions( + api: &Api, + n: Network, + address: &str, + f: &HistoryFilters, + currency: Option<&str>, + json: bool, + out: &mut dyn Write, +) -> Result<()> { + let mut query: Vec<(&str, String)> = currency + .map(|c| vec![("currency", c.to_owned())]) + .unwrap_or_default(); + if !f.filtered() { + let page = f.page.unwrap_or(1); + let env = api.get( + &["transactions", n.code(), address, &page.to_string()], + &query, + )?; + return emit(out, json, &env, |out, d: AddressTransactions| { + show_history(out, n, &d)?; + if d.transactions.is_empty() { + writeln!(out, "No transactions on page {page}.")?; + } else if d.transactions.len() >= PAGE_SIZE { + writeln!( + out, + "More: sochain transactions {n} {address} --page {}", + page + 1 + )?; + } + Ok(()) + }); + } + let optional = [ + ("from_height", f.from_height.map(|v| v.to_string())), + ("to_height", f.to_height.map(|v| v.to_string())), + ("from_time", f.from_time.clone()), + ("to_time", f.to_time.clone()), + ("direction", f.direction.as_ref().map(value_name)), + ("unconfirmed", f.unconfirmed.as_ref().map(value_name)), + ("page_size", f.page_size.map(|v| v.to_string())), + ("cursor", f.cursor.clone()), + ]; + query.extend(optional.into_iter().filter_map(|(k, v)| v.map(|v| (k, v)))); + let segments = ["transactions", n.code(), address]; + if f.csv { + query.push(("format", "csv".into())); + let raw = api.get_csv(&segments, &query)?; + out.write_all(&raw.body)?; + let next = raw + .headers + .get("x-next-cursor") + .and_then(|v| v.to_str().ok()) + .filter(|c| !c.is_empty()); + if let Some(cursor) = next { + // stderr, so the CSV on stdout stays clean. + eprintln!( + "sochain: more rows: sochain transactions {n} {address} --csv --cursor {}", + crate::sanitize::clean(cursor) + ); + } + return Ok(()); + } + if f.page_size.is_some_and(|s| s > 100) { + return Err(CliError::BadInput( + "--page-size is at most 100, or 500 with --csv".into(), + )); + } + let env = api.get(&segments, &query)?; + emit(out, json, &env, |out, d: AddressTransactions| { + if d.reorged == Some(true) { + writeln!( + out, + "Note: the chain reorganized under this cursor; rows may repeat." + )?; + } + show_history(out, n, &d)?; + if d.transactions.is_empty() && d.next_cursor.is_none() { + writeln!(out, "No matching transactions.")?; + } + if let Some(cursor) = &d.next_cursor { + writeln!( + out, + "More: sochain transactions {n} {address} --cursor {cursor}" + )?; + } + Ok(()) + }) +} + +fn show_history(out: &mut dyn Write, n: Network, d: &AddressTransactions) -> Result<()> { + let t = now(); + for h in &d.transactions { + let when = match h.block { + None => "in the mempool".to_owned(), + Some(_) => ago(h.time, t), + }; + let sign = if h.balance_change.is_negative() { + "-" + } else { + "+" + }; + let worth = h + .price + .as_ref() + .and_then(|p| worth(h.balance_change.abs(), p)) + .map(|w| format!(" ({w})")) + .unwrap_or_default(); + writeln!( + out, + " {sign}{} {n}{worth} {when} {}", + h.balance_change.abs(), + h.hash + )?; + } + Ok(()) +} + +fn show_unspent( + out: &mut dyn Write, + n: Network, + address: &str, + page: u32, + u: UnspentOutputs, +) -> Result<()> { + if u.outputs.is_empty() { + writeln!(out, "No unspent outputs on page {page}.")?; + return Ok(()); + } + for o in &u.outputs { + let block = o + .block + .map(|b| format!("block {}", group_thousands(b.into()))) + .unwrap_or_else(|| "unconfirmed".into()); + writeln!(out, " {} {n} {}:{} {block}", o.value, o.hash, o.index)?; + } + if u.outputs.len() >= PAGE_SIZE { + writeln!( + out, + "More: sochain unspent-outputs {n} {address} --page {}", + page + 1 + )?; + } + Ok(()) +} + +fn show_transaction(out: &mut dyn Write, n: Network, tx: Transaction) -> Result<()> { + writeln!(out, "Transaction {} on {}", tx.hash, n.name())?; + let status = match tx.block_height { + Some(h) if tx.confirmations > 0 => format!( + "{} in block {}", + plural(tx.confirmations, "confirmation"), + group_thousands(h.into()) + ), + _ => "waiting in the mempool (unconfirmed)".into(), + }; + writeln!(out, "Status {status} · {}", utc(tx.time))?; + writeln!(out, "From:")?; + if tx.inputs.is_empty() { + writeln!(out, " (no transparent inputs)")?; + } + for i in &tx.inputs { + let who = i.address.as_deref().unwrap_or("newly mined coins"); + match i.value { + Some(v) => writeln!(out, " {who} {v} {n}")?, + None => writeln!(out, " {who}")?, + } + } + writeln!(out, "To:")?; + if tx.outputs.is_empty() { + writeln!(out, " (no transparent outputs)")?; + } + for o in &tx.outputs { + let who = match (o.address.as_deref(), o.kind.as_deref()) { + (_, Some("nulldata")) | (Some("op_return"), _) => "data (OP_RETURN)", + (Some(a), _) => a, + (None, _) => "(no address)", + }; + writeln!(out, " {who} {} {n}", o.value)?; + } + if let Some(pools) = &tx.shielded { + let moved: Vec = pools + .iter() + .filter(|(k, _)| k.as_str() != "net") + .filter_map(|(k, v)| { + let a = v.as_str().and_then(Amount::parse)?; + (!a.is_zero()).then(|| format!("{k} {}{a}", if a.is_negative() { "" } else { "+" })) + }) + .collect(); + if !moved.is_empty() { + writeln!( + out, + "Shielded pools into the transparent side: {} {n}", + moved.join(", ") + )?; + } + } + let fee_worth = tx + .price + .as_ref() + .filter(|_| !tx.fee.is_zero()) + .and_then(|p| worth(tx.fee, p)) + .map(|w| format!(" ({w})")) + .unwrap_or_default(); + let vsize = tx + .vsize + .filter(|&v| v != tx.size) + .map(|v| format!(", {} vbytes", group_thousands(v.into()))) + .unwrap_or_default(); + writeln!( + out, + "Fee {} {n}{fee_worth} · {} bytes{vsize}", + tx.fee, + group_thousands(tx.size.into()) + )?; + if let Some(p) = tx.price.as_ref().filter(|p| !p.value.is_zero()) { + writeln!(out, "Price then 1 {n} = {}", format_price(p))?; + } + Ok(()) +} + +fn show_status(out: &mut dyn Write, hash: &str, s: TransactionStatus) -> Result<()> { + let line = match s.status.as_str() { + "pending" => "Pending: waiting in the mempool to be mined.".to_owned(), + "mined" => match s.block_height { + Some(h) => format!( + "Mined in block {} · {}.", + group_thousands(h.into()), + plural(s.confirmations.unwrap_or(0), "confirmation") + ), + None => "Mined.".to_owned(), + }, + "replaced" => { + let mut line = match &s.replaced_by { + Some(by) => { + let state = match (s.replaced_by_status.as_deref(), s.replaced_by_height) { + (Some("mined"), Some(h)) => { + format!(", mined in block {}", group_thousands(h.into())) + } + (Some("mined"), None) => ", mined".into(), + (Some("pending"), _) => ", waiting in the mempool".into(), + (Some("unknown"), _) => ", which also left the mempool unconfirmed".into(), + _ => String::new(), + }; + format!( + "Replaced: another transaction spends one of its inputs (a fee bump or double spend): {by}{state}." + ) + } + None => "Replaced: another transaction spends one of its inputs.".to_owned(), + }; + if let Some(ancestor) = &s.replaced_ancestor { + line += &format!(" It was replaced because a transaction it spends from was: {ancestor}."); + } + line + } + "dropped" => "Dropped: it left the mempool without being mined.".to_owned(), + "expired" => "Expired: it passed its expiry height without being mined.".to_owned(), + "unknown" => "Unknown: SoChain never saw this transaction, or it left the mempool more than about a day ago.".to_owned(), + other => format!("Status: {other}."), + }; + writeln!(out, "{line}")?; + if let Some(at) = s.removed_at { + writeln!(out, "Left the mempool {}.", utc(at))?; + } + writeln!(out, "Transaction id {hash}")?; + Ok(()) +} + +fn show_block(out: &mut dyn Write, n: Network, b: Block) -> Result<()> { + let t = now(); + writeln!( + out, + "{} block {} · {} · {} bytes", + n.name(), + group_thousands(b.height.into()), + plural(b.num_txs, "transaction"), + group_thousands(b.size.into()) + )?; + if b.is_orphan { + writeln!(out, "Orphaned: this block is not part of the main chain.")?; + } + writeln!(out, "Mined {} ({})", utc(b.time), ago(b.time, t))?; + writeln!( + out, + "Reward {} {n} plus {} {n} in fees · {} {n} moved", + b.reward, + b.fees, + b.value_out.display(2) + )?; + if let Some(p) = b.price.as_ref().filter(|p| !p.value.is_zero()) { + writeln!(out, "Price then 1 {n} = {}", format_price(p))?; + } + writeln!( + out, + "Confirmations {}", + group_thousands(b.confirmations.into()) + )?; + writeln!(out, "Difficulty {}", format_difficulty(&b.difficulty))?; + writeln!(out, "Hash {}", b.hash)?; + if let Some(prev) = &b.previous_block_hash { + writeln!(out, "Previous block {prev}")?; + } + match &b.next_block_hash { + Some(next) => writeln!(out, "Next block {next}")?, + None => writeln!(out, "Next block not mined yet")?, + } + Ok(()) +} + +fn show_latest_blocks(out: &mut dyn Write, n: Network, r: LatestBlocks) -> Result<()> { + let t = now(); + for b in &r.blocks { + let miner = b + .miner + .as_deref() + .map(|m| format!(" · paid to {m}")) + .unwrap_or_default(); + writeln!( + out, + "Block {} · {} · {} · {} {n} reward and fees{miner}", + group_thousands(b.height.into()), + ago(b.time, t), + plural(b.num_txs, "transaction"), + b.reward_and_fees.display(4) + )?; + } + Ok(()) +} + +fn show_network_info(out: &mut dyn Write, n: Network, i: NetworkInfo) -> Result<()> { + writeln!( + out, + "{} · latest block {}", + n.name(), + group_thousands(i.block_count.into()) + )?; + writeln!(out, "Hash {}", i.best_block_hash)?; + writeln!( + out, + "Mempool {} waiting, {} bytes", + plural(i.mempool.mempool_txs, "transaction"), + group_thousands(i.mempool.mempool_size.into()) + )?; + if let Some(next) = i.mempool.blocks.first() { + writeln!( + out, + "Next block about {}, median fee rate {} {}", + plural(next.num_txs, "transaction"), + rate( + next.median_fee_rate_precise.as_deref(), + next.median_fee_rate + ), + n.fee_rate_unit() + )?; + } + writeln!(out, "Hashrate {}", format_hashrate(&i.hashrate))?; + Ok(()) +} + +/// `fees --json`: the fee part of network_info, in the same envelope. +fn fees_json(n: Network, d: &Value) -> Value { + let blocks = d["mempool"]["blocks"].clone(); + let suggested = blocks[0]["median_fee_rate_precise"].clone(); + json!({ + "network": n.code(), + "unit": n.fee_rate_unit(), + "suggested_fee_rate": suggested, + "mempool_txs": d["mempool"]["mempool_txs"], + "mempool_size": d["mempool"]["mempool_size"], + "blocks": blocks, + }) +} + +fn show_fees(out: &mut dyn Write, n: Network, i: &NetworkInfo) -> Result<()> { + let unit = n.fee_rate_unit(); + let blocks = &i.mempool.blocks; + if let Some(next) = blocks.first() { + if let (Some(fee), Some(grace)) = (next.marginal_fee, next.grace_actions) { + writeln!( + out, + "Zcash fees (ZIP 317): {} zatoshis per logical action, with {grace} actions minimum.", + group_thousands(fee.into()) + )?; + } else { + writeln!( + out, + "Suggested fee rate {} {unit} (the next block's median).", + rate( + next.median_fee_rate_precise.as_deref(), + next.median_fee_rate + ) + )?; + } + } + writeln!( + out, + "{} waiting in the mempool ({} bytes).", + plural(i.mempool.mempool_txs, "transaction"), + group_thousands(i.mempool.mempool_size.into()) + )?; + if blocks.is_empty() { + return Ok(()); + } + writeln!(out, "Projected blocks, fee rates in {unit}:")?; + for b in blocks.iter().filter(|b| b.num_txs > 0) { + let label = if b.block_num == 1 { + "Next block".to_owned() + } else { + format!("Block {}", b.block_num) + }; + writeln!( + out, + " {label:<10} min {:>8} median {:>8} max {:>8} {}", + rate(b.min_fee_rate_precise.as_deref(), b.min_fee_rate), + rate(b.median_fee_rate_precise.as_deref(), b.median_fee_rate), + rate(b.max_fee_rate_precise.as_deref(), b.max_fee_rate), + plural(b.num_txs, "transaction") + )?; + } + Ok(()) +} + +/// A fee rate: the API's precise decimal string when it sends one. +fn rate(precise: Option<&str>, rounded: f64) -> String { + precise + .map(str::to_owned) + .unwrap_or_else(|| format_number(rounded)) +} + +fn show_price(out: &mut dyn Write, n: Network, historic: bool, p: PriceData) -> Result<()> { + if p.value.is_zero() && n.is_testnet() { + writeln!(out, "Testnet coins have no price.")?; + return Ok(()); + } + let price = format_price(&Price { + value: p.value, + currency: p.currency.clone(), + }); + let when = match (historic, p.as_of) { + (true, _) => " (that UTC day's opening price)".to_owned(), + (false, Some(t)) => format!(" as of {}", utc(t)), + _ => String::new(), + }; + writeln!(out, "1 {n} = {price}{when}")?; + if p.method.as_deref() == Some("usd_fx") { + let date = p.fx_date.map(|d| format!(" of {d}")).unwrap_or_default(); + writeln!( + out, + "Converted from US dollars at the ECB reference rate{date}." + )?; + } + Ok(()) +} + +fn show_rich_list(out: &mut dyn Write, n: Network, r: RichList) -> Result<()> { + let pages = r.total_rows.div_ceil(r.page_size.max(1).into()).max(1); + writeln!( + out, + "Largest {} holders at block {}, page {} of {pages} · supply {} {n}", + n.name(), + group_thousands(r.height.into()), + r.page, + r.supply.display(0) + )?; + if n.is_zcash() { + writeln!(out, "(transparent addresses only)")?; + } + for row in &r.rows { + let who = row.address.as_deref().unwrap_or("(non-standard script)"); + let share = row + .share_of_supply + .map(|s| format!(" {:.4}%", s * 100.0)) + .unwrap_or_default(); + writeln!( + out, + "{:>5} {who} {} {n}{share}", + row.rank, + row.balance.display(2) + )?; + } + Ok(()) +} + +fn show_rank(out: &mut dyn Write, n: Network, r: AddressRank) -> Result<()> { + writeln!(out, "{} holds {} {n}", r.address, r.balance)?; + match (r.rank, r.rank_limit) { + (Some(rank), _) => writeln!( + out, + "Rank {} among {} holders", + group_thousands(rank.into()), + n.name() + )?, + (None, Some(limit)) => writeln!( + out, + "Not in the top {} holders", + group_thousands(limit.into()) + )?, + (None, None) => writeln!(out, "Not ranked")?, + } + if let Some(share) = r.share_of_supply { + writeln!(out, "Share of supply {}", format_share(share))?; + } + Ok(()) +} + +fn show_balance_history( + out: &mut dyn Write, + n: Network, + address: &str, + h: BalanceHistory, + last: usize, +) -> Result<()> { + let total = h.points.len(); + let shown = if last == 0 { total } else { last.min(total) }; + let points = &h.points[total - shown..]; + let range = if shown < total { + format!( + ", last {} of {} points (--last 0 for all)", + group_thousands(shown as i128), + group_thousands(total as i128) + ) + } else { + String::new() + }; + writeln!(out, "Balance of {address} by {}{range}", h.interval)?; + if points.is_empty() { + writeln!(out, "No activity in this range.")?; + } + for p in points { + let txs = |c: Option| { + c.map(|c| format!(" ({})", plural(c, "transaction"))) + .unwrap_or_default() + }; + let mut moves = vec![]; + if !p.received.is_zero() { + moves.push(format!("received {}{}", p.received, txs(p.tx_in))); + } + if !p.sent.is_zero() { + moves.push(format!("sent {}{}", p.sent, txs(p.tx_out))); + } + let moves = if moves.is_empty() { + String::new() + } else { + format!(" {}", moves.join(", ")) + }; + writeln!(out, " {} {} {n}{moves}", utc_date(p.t), p.v)?; + } + Ok(()) +} + +fn show_poisoning(out: &mut dyn Write, n: Network, p: AddressPoisoning) -> Result<()> { + if p.building { + writeln!( + out, + "Note: this check is still catching up on {}'s history; results may be incomplete.", + n.name() + )?; + } + if p.findings.is_empty() { + writeln!(out, "No address poisoning found for {}.", p.address)?; + return Ok(()); + } + writeln!( + out, + "WARNING: {} sent {} dust imitating addresses it really dealt with. \ + Never copy an address from transaction history.", + plural(p.findings.len() as u64, "look-alike address"), + p.address + )?; + for f in &p.findings { + let amount = f + .amount + .map(|a| format!(" sent {a} {n}")) + .unwrap_or_default(); + let txid = f + .dust_txid + .as_deref() + .map(|t| format!(" · {t}")) + .unwrap_or_default(); + let paid = if f.receiver_paid == Some(true) { + " (it was paid: check that payment)" + } else { + "" + }; + writeln!( + out, + " {}{amount}, imitating {}{txid}{paid}", + f.sender, f.imitates + )?; + } + Ok(()) +} + +fn show_counterparties(out: &mut dyn Write, n: Network, c: Counterparties) -> Result<()> { + if c.building { + writeln!( + out, + "Note: still catching up on {}'s history; results may be incomplete.", + n.name() + )?; + } + let window = c + .window_blocks + .map(|b| format!(" in the last {}", plural(b, "block"))) + .unwrap_or_default(); + if c.counterparties.is_empty() { + writeln!( + out, + "No significant counterparties for {}{window}.", + c.address + )?; + return Ok(()); + } + writeln!( + out, + "Counterparties of {}{window}, newest first:", + c.address + )?; + for p in &c.counterparties { + let (verb, sign) = match p.direction.as_str() { + "received" => ("from", "+"), + "sent" => ("to", "-"), + _ => ("with", ""), + }; + let at = p + .height + .map(|h| format!(" block {}", group_thousands(h.into()))) + .unwrap_or_default(); + writeln!( + out, + " {sign}{} {n} {verb} {}{at} {}", + p.amount, p.address, p.txid + )?; + } + Ok(()) +} + +fn show_chart(out: &mut dyn Write, n: Network, r: Chart, last: usize) -> Result<()> { + let total = r.points.len(); + let shown = if last == 0 { total } else { last.min(total) }; + let points = &r.points[total - shown..]; + if shown < total { + writeln!( + out, + "{} {} by {}, last {} of {} points (--last 0 for all)", + n, + r.series, + r.interval, + group_thousands(shown as i128), + group_thousands(total as i128) + )?; + } else { + writeln!(out, "{} {} by {}", n, r.series, r.interval)?; + } + let rows: Vec<(String, String)> = points + .iter() + .map(|p| { + ( + point_label(p, &r.interval), + format_chart_value(&r.series, &p.v), + ) + }) + .collect(); + let width = rows + .iter() + .map(|(l, _)| l.chars().count()) + .max() + .unwrap_or(0); + for (label, v) in rows { + writeln!(out, " {label: String { + match (interval, p.height) { + ("block", Some(h)) => format!("block {}", group_thousands(h.into())), + ("minute" | "hour", _) => utc_minute(p.t), + _ => utc_date(p.t), + } +} + +fn show_account(out: &mut dyn Write, a: &Value) -> Result<()> { + if let Some(plan) = a["plan"]["name"].as_str() { + writeln!(out, "Plan {plan}")?; + } + let used = &a["num_requests_used"]; + if let (Some(today), Some(yesterday)) = (used["today"].as_u64(), used["yesterday"].as_u64()) { + writeln!( + out, + "Requests today {} · yesterday {}", + group_thousands(today.into()), + group_thousands(yesterday.into()) + )?; + } + if let Some(month) = used["this_month"].as_u64().or(used["month"].as_u64()) { + writeln!(out, "Requests this month {}", group_thousands(month.into()))?; + } + if let Some(ends) = a["plan"]["current_period_ends_at"].as_str() { + let renews = if a["plan"]["will_renew_at_period_end"] == true { + "renews" + } else { + "ends" + }; + writeln!(out, "Current period {renews} {ends}")?; + } + if let Some(name) = a["key"]["name"].as_str() { + let access = match a["key"]["access"].as_str() { + Some("read_only") => " (read-only)", + Some("full") => " (full access)", + _ => "", + }; + writeln!(out, "Key \"{name}\"{access}")?; + } + Ok(()) +} + +/// Chart values are numbers for most series and objects for breakdowns +/// (pool_share, holder_share, transfers, mweb_flows, zcash_pools). Amounts +/// arrive as decimal strings; holder_share is fractions of the supply. +fn format_chart_value(series: &str, v: &Value) -> String { + match v { + Value::Null => "no data".into(), + Value::Number(n) => n + .as_f64() + .map(format_number) + .unwrap_or_else(|| n.to_string()), + Value::String(s) => Amount::parse(s) + .map(|a| a.display(8)) + .unwrap_or_else(|| s.clone()), + Value::Object(map) => map + .iter() + .map(|(k, v)| match (series, v) { + ("holder_share", Value::Number(n)) => { + format!("{k} {:.2}%", n.as_f64().unwrap_or(0.0) * 100.0) + } + (_, Value::Object(o)) if o.contains_key("count") => { + let count = o["count"].as_f64().map(format_number).unwrap_or_default(); + match o + .get("sent") + .and_then(Value::as_str) + .and_then(Amount::parse) + { + Some(sent) => format!("{k} {count} ({})", sent.display(0)), + None => format!("{k} {count}"), + } + } + _ => format!("{k} {}", format_chart_value(series, v)), + }) + .collect::>() + .join(", "), + other => other.to_string(), + } +} + +/// What `amount` coins were worth at `price`, if the price is known. +fn worth(amount: Amount, price: &Price) -> Option { + if price.value.is_zero() { + return None; + } + Some(format_money( + amount.to_f64() * price.value.to_f64(), + &price.currency, + )) +} + +/// A price per coin: `$84,455.38`, `€0.0939` style precision for small prices. +fn format_price(p: &Price) -> String { + let v = p.value.to_f64(); + let text = if v >= 1.0 { + format_money_digits(v, 2) + } else { + let s = format!("{v:.6}"); + s.trim_end_matches('0').trim_end_matches('.').to_owned() + }; + with_currency(&text, &p.currency) +} + +/// A money amount: `$0.52`, `$1,234.56`, `$33,100`; `<$0.01` for dust. +fn format_money(v: f64, currency: &str) -> String { + if v > 0.0 && v < 0.01 { + return format!("<{}", with_currency("0.01", currency)); + } + let text = if v < 10_000.0 { + format_money_digits(v, 2) + } else { + group_thousands(v.round() as i128) + }; + with_currency(&text, currency) +} + +fn format_money_digits(v: f64, digits: u32) -> String { + let scale = 10f64.powi(digits as i32); + let cents = (v * scale).round() as i128; + let unit = scale as i128; + format!( + "{}.{:0width$}", + group_thousands(cents / unit), + (cents % unit).abs(), + width = digits as usize + ) +} + +fn with_currency(text: &str, currency: &str) -> String { + match currency { + "USD" => format!("${text}"), + other => format!("{text} {other}"), + } +} + +/// A fraction of the supply as a percentage, with enough digits to show +/// small holdings: `1.2371%`, `0.00002%`. +fn format_share(f: f64) -> String { + let pct = f * 100.0; + if pct == 0.0 || pct >= 0.01 { + return format!("{pct:.4}%"); + } + let decimals = (2 - pct.log10().floor() as i32).clamp(4, 12) as usize; + let s = format!("{pct:.decimals$}"); + format!("{}%", s.trim_end_matches('0').trim_end_matches('.')) +} + +/// Hashes per second with an SI prefix: `925.64 EH/s`. Falls back to the raw +/// text if it is not a number. +fn format_hashrate(raw: &str) -> String { + let Ok(h) = raw.trim().parse::() else { + return raw.to_owned(); + }; + const UNITS: [&str; 8] = [ + "H/s", "kH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s", "ZH/s", + ]; + let mut value = h; + let mut unit = 0; + while value >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + format!("{value:.2} {}", UNITS[unit]) +} + +/// `47,952,738.33`. Falls back to the raw text if it is not a number. +fn format_difficulty(raw: &str) -> String { + match raw.trim().parse::() { + Ok(d) if d.is_finite() => format_money_digits(d, 2), + _ => raw.to_owned(), + } +} + +fn plural(n: u64, noun: &str) -> String { + format!( + "{} {noun}{}", + group_thousands(n.into()), + match (n, noun.ends_with('s')) { + (1, _) => "", + (_, true) => "es", + (_, false) => "s", + } + ) +} + +fn format_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e18 { + group_thousands(v as i128) + } else if v.abs() >= 1000.0 { + group_thousands(v.round() as i128) + } else { + let s = format!("{v:.4}"); + s.trim_end_matches('0').trim_end_matches('.').to_owned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn price(v: &str, c: &str) -> Price { + Price { + value: Amount::parse(v).unwrap(), + currency: c.into(), + } + } + + #[test] + fn formats_money() { + assert_eq!(format_money(33_100.4, "USD"), "$33,100"); + assert_eq!(format_money(1234.567, "USD"), "$1,234.57"); + assert_eq!(format_money(0.52, "EUR"), "0.52 EUR"); + assert_eq!(format_money(0.001, "USD"), "<$0.01"); + assert_eq!(format_money(0.0, "USD"), "$0.00"); + assert_eq!(format_price(&price("84455.38", "USD")), "$84,455.38"); + assert_eq!(format_price(&price("0.0938771", "USD")), "$0.093877"); + assert_eq!( + format_price(&price("34028.14955012", "EUR")), + "34,028.15 EUR" + ); + } + + #[test] + fn values_amounts_at_a_price() { + let p = price("80000", "USD"); + assert_eq!( + worth(Amount::parse("0.5").unwrap(), &p).as_deref(), + Some("$40,000") + ); + assert_eq!(worth(Amount::parse("1").unwrap(), &price("0", "USD")), None); + } + + #[test] + fn formats_shares() { + assert_eq!(format_share(0.012370660198574827), "1.2371%"); + assert_eq!(format_share(1.9518949928633102e-7), "0.0000195%"); + assert_eq!(format_share(0.0), "0.0000%"); + } + + #[test] + fn formats_hashrate_and_difficulty() { + assert_eq!(format_hashrate("925644484990023500000"), "925.64 EH/s"); + assert_eq!(format_hashrate("999"), "999.00 H/s"); + assert_eq!(format_hashrate("n/a"), "n/a"); + assert_eq!( + format_difficulty("132716002350731.3"), + "132,716,002,350,731.30" + ); + assert_eq!(format_difficulty("0.5"), "0.50"); + } + + #[test] + fn formats_chart_values() { + assert_eq!(format_chart_value("tx_count", &json!(620424)), "620,424"); + assert_eq!( + format_chart_value("price_usd", &json!("82581.40000000")), + "82,581.4" + ); + assert_eq!( + format_chart_value("holder_share", &json!({"top10": 0.0601, "top100": 0.5})), + "top10 6.01%, top100 50.00%" + ); + assert_eq!( + format_chart_value("pool_share", &json!({"Foundry": 446, "unknown": 243})), + "Foundry 446, unknown 243" + ); + assert_eq!( + format_chart_value( + "transfers", + &json!({"lt_1": {"count": 11407, "sent": "227.928"}}) + ), + "lt_1 11,407 (228)" + ); + assert_eq!(format_chart_value("tx_count", &Value::Null), "no data"); + } + + #[test] + fn formats_numbers() { + assert_eq!(format_number(1234.0), "1,234"); + assert_eq!(format_number(1234.6), "1,235"); + assert_eq!(format_number(0.12345), "0.1235"); + assert_eq!(plural(1, "block"), "1 block"); + assert_eq!(plural(2, "address"), "2 addresses"); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..7cf7b05 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,102 @@ +//! Error model shared by every command. +//! +//! Each error has a stable, machine-readable `code` and a documented process +//! exit code. Both are public interface: changing either is a breaking change. + +use serde_json::{Value, json}; + +/// Process exit codes, documented in README.md and AGENTS.md. +pub mod exit { + pub const OK: u8 = 0; + pub const OTHER: u8 = 1; + pub const BAD_INPUT: u8 = 2; + pub const NOT_FOUND: u8 = 3; + pub const UNAVAILABLE: u8 = 4; + pub const RATE_LIMITED: u8 = 5; + pub const UNAUTHORIZED: u8 = 6; +} + +#[derive(Debug, thiserror::Error)] +pub enum CliError { + /// The request was rejected as invalid (bad arguments, or HTTP 400). + #[error("{0}")] + BadInput(String), + + #[error("{0}")] + NotFound(String), + + /// SoChain could not be reached or could not answer right now. + #[error("{0}")] + Unavailable(String), + + #[error("{message}")] + RateLimited { + message: String, + retry_after_secs: u64, + }, + + /// The API key was missing, invalid or not allowed (HTTP 401 or 403). + #[error("{0}")] + Unauthorized(String), + + #[error("{0}")] + Other(String), + + /// Stdout was closed (e.g. `sochain … | head`). Not an error for the + /// user: the process exits 0 without a message. + #[error("output closed")] + OutputClosed, +} + +impl From for CliError { + fn from(e: std::io::Error) -> Self { + if e.kind() == std::io::ErrorKind::BrokenPipe { + CliError::OutputClosed + } else { + CliError::Other(format!("could not write output: {e}")) + } + } +} + +impl CliError { + /// Stable identifier for scripts and agents to branch on. + pub fn code(&self) -> &'static str { + match self { + CliError::BadInput(_) => "BAD_INPUT", + CliError::NotFound(_) => "NOT_FOUND", + CliError::Unavailable(_) => "UNAVAILABLE", + CliError::RateLimited { .. } => "RATE_LIMITED", + CliError::Unauthorized(_) => "UNAUTHORIZED", + CliError::Other(_) | CliError::OutputClosed => "OTHER", + } + } + + pub fn exit_code(&self) -> u8 { + match self { + CliError::BadInput(_) => exit::BAD_INPUT, + CliError::NotFound(_) => exit::NOT_FOUND, + CliError::Unavailable(_) => exit::UNAVAILABLE, + CliError::RateLimited { .. } => exit::RATE_LIMITED, + CliError::Unauthorized(_) => exit::UNAUTHORIZED, + CliError::Other(_) => exit::OTHER, + CliError::OutputClosed => exit::OK, + } + } + + /// Same envelope as the API's failures, plus the stable `code`. + pub fn to_json(&self) -> Value { + let mut data = json!({ + "error_message": self.to_string(), + "code": self.code(), + }); + if let CliError::RateLimited { + retry_after_secs, .. + } = self + { + data["retry_after_secs"] = json!(retry_after_secs); + } + json!({ "status": "fail", "data": data }) + } +} + +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f8684a5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,101 @@ +//! `sochain`: Bitcoin, Litecoin, Dogecoin and Zcash blockchain data from your +//! terminal, via SoChain. + +mod amount; +mod api; +mod auth; +mod cli; +mod commands; +mod error; +mod mcp; +mod model; +mod network; +mod sanitize; +mod schema; +mod secret; +mod skill; +mod time; + +use std::io::{self, Write}; +use std::process::ExitCode; + +use clap::Parser; +use clap::error::ErrorKind; + +use crate::cli::Cli; +use crate::error::{CliError, exit}; + +/// Printed by `sochain guide`; the same text is AGENTS.md in the repository. +pub const GUIDE: &str = include_str!("../AGENTS.md"); + +fn main() -> ExitCode { + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(e) => return usage_error(e), + }; + let json = cli.json; + let stdout = io::BufWriter::new(io::stdout().lock()); + let mut out: Box = if json { + Box::new(stdout) + } else { + Box::new(sanitize::Sanitized(stdout)) + }; + let result = commands::run(cli, &mut out).and_then(|()| Ok(out.flush()?)); + match result { + Ok(()) | Err(CliError::OutputClosed) => ExitCode::from(exit::OK), + Err(e) => { + // Whatever was printed before the failure still goes out. + let _ = out.flush(); + report(&e, json); + ExitCode::from(e.exit_code()) + } + } +} + +fn report(e: &CliError, json: bool) { + let mut err = io::stderr().lock(); + let _ = if json { + writeln!(err, "{}", e.to_json()) + } else { + writeln!(err, "sochain: {}", sanitize::clean(&e.to_string())) + }; +} + +/// Help and --version exit 0. Other argument errors exit 2, as JSON on +/// stderr when --json was asked for. +fn usage_error(e: clap::Error) -> ExitCode { + if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) { + let _ = e.print(); + return ExitCode::from(exit::OK); + } + if json_requested() { + let message = e.kind().to_string(); + let detail = e.to_string(); + let first_line = detail + .lines() + .next() + .and_then(|l| l.strip_prefix("error: ")) + .unwrap_or(&message); + report(&CliError::BadInput(first_line.to_owned()), true); + } else { + let _ = e.print(); + } + ExitCode::from(exit::BAD_INPUT) +} + +/// Whether --json was given, read without clap because parsing failed. +fn json_requested() -> bool { + let flag = std::env::args_os() + .skip(1) + .take_while(|a| a != "--") + .any(|a| a == "--json"); + let env = std::env::var("SOCHAIN_JSON") + .map(|v| { + !matches!( + v.to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" | "off" + ) + }) + .unwrap_or(false); + flag || env +} diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..be4589c --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,482 @@ +//! `sochain mcp`: registers the SoChain MCP server (hosted at sochain.com; +//! nothing runs locally) with AI agents, using each agent's own command-line +//! tool. The CLI never edits another program's config files; for agents +//! without such a tool, `--print` shows what to add by hand. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::auth::ENV_KEY; +use crate::error::{CliError, Result}; + +pub const SERVER_NAME: &str = "sochain"; +pub const SERVER_URL: &str = "https://sochain.com/mcp"; + +/// How the agent authenticates to the server. +#[derive(Clone)] +pub enum Auth { + /// No key: the keyless allowance per IP address. + None, + /// The agent reads SOCHAIN_API_KEY from its environment when it starts + /// (Codex; Claude Code project files). + EnvVar, + /// The key itself is written to the agent's own user config (Claude Code + /// user scope, which has no env-var reference for headers). + Key(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Agent { + Codex, + Claude, +} + +/// Codex first, then Claude Code. +const AGENTS: [Agent; 2] = [Agent::Codex, Agent::Claude]; + +impl Agent { + fn id(self) -> &'static str { + match self { + Agent::Codex => "codex", + Agent::Claude => "claude", + } + } + + fn name(self) -> &'static str { + match self { + Agent::Codex => "Codex", + Agent::Claude => "Claude Code", + } + } + + fn program(self) -> &'static str { + self.id() + } + + /// Codex has no per-project MCP configuration. + fn supports_project(self) -> bool { + matches!(self, Agent::Claude) + } + + fn add_args(self, project: bool, auth: &Auth) -> Vec { + let mut args: Vec = match self { + Agent::Codex => ["mcp", "add", SERVER_NAME, "--url", SERVER_URL] + .map(String::from) + .to_vec(), + Agent::Claude => [ + "mcp", + "add", + "--transport", + "http", + "--scope", + if project { "project" } else { "user" }, + SERVER_NAME, + SERVER_URL, + ] + .map(String::from) + .to_vec(), + }; + match (self, auth) { + (_, Auth::None) => {} + (Agent::Codex, _) => { + args.extend(["--bearer-token-env-var".into(), ENV_KEY.into()]); + } + (Agent::Claude, Auth::EnvVar) => { + // Claude Code expands ${VAR} in project .mcp.json files. + args.extend(["--header".into(), format!("API-KEY: ${{{ENV_KEY}}}")]); + } + (Agent::Claude, Auth::Key(key)) => { + args.extend(["--header".into(), format!("API-KEY: {key}")]); + } + } + args + } + + fn remove_args(self, project: bool) -> Vec<&'static str> { + match self { + Agent::Codex => vec!["mcp", "remove", SERVER_NAME], + Agent::Claude => vec![ + "mcp", + "remove", + "--scope", + if project { "project" } else { "user" }, + SERVER_NAME, + ], + } + } + + /// The URL of the agent's `sochain` server, if it has one. + fn configured_url(self, program: &Path) -> Result> { + match self { + Agent::Codex => { + let out = run(program, &["mcp", "get", SERVER_NAME, "--json"])?; + if !out.status.success() { + return Ok(None); + } + if String::from_utf8_lossy(&out.stdout).contains(SERVER_URL) { + return Ok(Some(SERVER_URL.to_owned())); + } + let v: Value = serde_json::from_slice(&out.stdout).unwrap_or(Value::Null); + let url = v["transport"]["url"].as_str().or(v["url"].as_str()); + Ok(Some(url.unwrap_or_default().to_owned())) + } + Agent::Claude => { + let out = run(program, &["mcp", "get", SERVER_NAME])?; + if !out.status.success() { + return Ok(None); + } + let text = String::from_utf8_lossy(&out.stdout); + if text.contains(SERVER_URL) { + return Ok(Some(SERVER_URL.to_owned())); + } + Ok(Some( + text.lines() + .find_map(|l| l.trim().strip_prefix("URL:")) + .map(|u| u.trim().to_owned()) + .unwrap_or_default(), + )) + } + } + } +} + +/// Finds a program on PATH, including Windows `.exe` and `.cmd` shims. +fn find_program(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + let names: Vec = if cfg!(windows) { + vec![ + format!("{name}.exe"), + format!("{name}.cmd"), + name.to_owned(), + ] + } else { + vec![name.to_owned()] + }; + std::env::split_paths(&path) + .flat_map(|dir| names.iter().map(move |n| dir.join(n))) + .find(|p| p.is_file()) +} + +fn run>(program: &Path, args: &[S]) -> Result { + Command::new(program) + .args(args) + .output() + .map_err(|e| CliError::Other(format!("could not run {}: {e}", program.display()))) +} + +#[derive(Serialize)] +struct Outcome { + agent: &'static str, + result: String, +} + +fn report(outcomes: &[Outcome], json: bool, out: &mut dyn Write) -> Result<()> { + if json { + writeln!( + out, + "{:#}", + json!({ "status": "success", "data": { "server": SERVER_URL, "agents": outcomes } }) + )?; + } else { + for o in outcomes { + let name = AGENTS + .iter() + .find(|a| a.id() == o.agent) + .map(|a| a.name()) + .unwrap_or(o.agent); + writeln!(out, "{name}: {}", o.result)?; + } + } + Ok(()) +} + +fn found_agents() -> Result> { + let found: Vec<_> = AGENTS + .iter() + .filter_map(|&a| find_program(a.program()).map(|p| (a, p))) + .collect(); + if found.is_empty() { + return Err(CliError::NotFound( + "neither `codex` nor `claude` was found on PATH; \ + run `sochain mcp install --print` for other apps" + .into(), + )); + } + Ok(found) +} + +/// The agent's own error, with anything key-shaped kept out of it. +fn command_failed(agent: Agent, out: &Output, auth: &Auth) -> CliError { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + let detail = stderr + .lines() + .chain(stdout.lines()) + .find(|l| !l.trim().is_empty()) + .unwrap_or("no details"); + let detail = match auth { + Auth::Key(key) => detail.replace(key.as_str(), ""), + _ => detail.to_owned(), + }; + CliError::Other(format!( + "{} could not update its MCP servers: {detail}", + agent.name() + )) +} + +/// The auth an agent gets: Claude Code's user scope stores the key itself, +/// everything else references SOCHAIN_API_KEY. +fn auth_for(agent: Agent, project: bool, auth: &Auth) -> Auth { + match (agent, auth) { + (Agent::Claude, Auth::Key(_)) if project => Auth::EnvVar, + (Agent::Codex, Auth::Key(_)) => Auth::EnvVar, + _ => auth.clone(), + } +} + +pub fn install( + project: bool, + auth: Auth, + force: bool, + json: bool, + out: &mut dyn Write, +) -> Result<()> { + let mut outcomes = Vec::new(); + let mut skipped = false; + let keyed = !matches!(auth, Auth::None); + for (agent, program) in found_agents()? { + if project && !agent.supports_project() { + outcomes.push(Outcome { + agent: agent.id(), + result: "skipped: no per-project setting; run without --project".into(), + }); + continue; + } + let auth = auth_for(agent, project, &auth); + let result = match agent.configured_url(&program)? { + // Re-adding with a key replaces a keyless entry. + Some(url) if url == SERVER_URL && !keyed && !force => "already added".to_owned(), + Some(url) if url != SERVER_URL && !force => { + skipped = true; + format!( + "skipped: a server named {SERVER_NAME} already points to {} (use --force to replace)", + if url.is_empty() { + "something else" + } else { + &url + } + ) + } + existing => { + if existing.is_some() { + let removed = run(&program, &agent.remove_args(project))?; + if !removed.status.success() { + return Err(command_failed(agent, &removed, &auth)); + } + } + let added = run(&program, &agent.add_args(project, &auth))?; + if !added.status.success() { + return Err(command_failed(agent, &added, &auth)); + } + let how = match auth { + Auth::None => "", + Auth::EnvVar => ", reading the key from SOCHAIN_API_KEY", + Auth::Key(_) => ", with your API key", + }; + format!( + "{}{how}", + if existing.is_some() { + "replaced" + } else { + "added" + } + ) + } + }; + outcomes.push(Outcome { + agent: agent.id(), + result, + }); + } + report(&outcomes, json, out)?; + if !json { + writeln!( + out, + "Other apps (Cursor, VS Code, Claude.ai, ChatGPT): sochain mcp install --print" + )?; + } + if skipped { + return Err(CliError::Other( + "some agents were not changed because they already had a different sochain server" + .into(), + )); + } + Ok(()) +} + +pub fn status(json: bool, out: &mut dyn Write) -> Result<()> { + let mut outcomes = Vec::new(); + for agent in AGENTS { + let result = match find_program(agent.program()) { + None => "not found on PATH".to_owned(), + Some(program) => match agent.configured_url(&program)? { + None => "not added".to_owned(), + Some(url) if url == SERVER_URL => format!("added ({SERVER_URL})"), + Some(url) => format!("a server named {SERVER_NAME} points to {url}"), + }, + }; + outcomes.push(Outcome { + agent: agent.id(), + result, + }); + } + report(&outcomes, json, out) +} + +pub fn uninstall(project: bool, force: bool, json: bool, out: &mut dyn Write) -> Result<()> { + let mut outcomes = Vec::new(); + let mut skipped = false; + for (agent, program) in found_agents()? { + if project && !agent.supports_project() { + outcomes.push(Outcome { + agent: agent.id(), + result: "skipped: no per-project setting".into(), + }); + continue; + } + let result = match agent.configured_url(&program)? { + None => "not added".to_owned(), + Some(url) if url != SERVER_URL && !force => { + skipped = true; + format!("skipped: {SERVER_NAME} points to {url}, not SoChain (use --force)") + } + Some(_) => { + let removed = run(&program, &agent.remove_args(project))?; + if !removed.status.success() { + return Err(command_failed(agent, &removed, &Auth::None)); + } + "removed".to_owned() + } + }; + outcomes.push(Outcome { + agent: agent.id(), + result, + }); + } + report(&outcomes, json, out)?; + if skipped { + return Err(CliError::Other( + "some agents were not changed because their sochain server is not SoChain's".into(), + )); + } + Ok(()) +} + +/// Setup for every supported app, for copying by hand. Changes nothing, and +/// never prints a key: commands use $SOCHAIN_API_KEY. +pub fn print(project: bool, with_key: bool, json: bool, out: &mut dyn Write) -> Result<()> { + let header = format!("API-KEY: ${ENV_KEY}"); + let codex = if with_key { + format!("codex mcp add {SERVER_NAME} --url {SERVER_URL} --bearer-token-env-var {ENV_KEY}") + } else { + format!("codex mcp add {SERVER_NAME} --url {SERVER_URL}") + }; + let scope = if project { " --scope project" } else { "" }; + let claude = if with_key { + format!( + "claude mcp add --transport http{scope} {SERVER_NAME} {SERVER_URL} --header \"{header}\"" + ) + } else { + format!("claude mcp add --transport http{scope} {SERVER_NAME} {SERVER_URL}") + }; + let headers = json!({ "API-KEY": "" }); + let (cursor, vscode) = if with_key { + ( + json!({ "mcpServers": { SERVER_NAME: { "url": SERVER_URL, "headers": headers } } }), + json!({ "servers": { SERVER_NAME: { "type": "http", "url": SERVER_URL, "headers": headers } } }), + ) + } else { + ( + json!({ "mcpServers": { SERVER_NAME: { "url": SERVER_URL } } }), + json!({ "servers": { SERVER_NAME: { "type": "http", "url": SERVER_URL } } }), + ) + }; + let cursor_file = if project { + ".cursor/mcp.json" + } else { + "~/.cursor/mcp.json" + }; + if json { + let data = json!({ + "server": SERVER_URL, + "codex": { "command": codex }, + "claude_code": { "command": claude }, + "cursor": { "file": cursor_file, "add": cursor }, + "vscode": { "file": ".vscode/mcp.json", "add": vscode }, + "claude_app": "Settings → Connectors → Add custom connector → the server URL (no key)", + "chatgpt": "Settings → Apps & Connectors → Advanced settings → turn on Developer mode, then Create → the server URL, No authentication", + }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + return Ok(()); + } + let auth = if with_key { + "with your API key" + } else { + "no key needed; add one with --use-api-key for your plan's limits" + }; + writeln!( + out, + "SoChain MCP server: {SERVER_URL} (Streamable HTTP; {auth})\n" + )?; + writeln!(out, "Codex:\n {codex}\n")?; + writeln!(out, "Claude Code:\n {claude}\n")?; + writeln!(out, "Cursor: add to {cursor_file}\n {cursor}\n")?; + writeln!(out, "VS Code: add to .vscode/mcp.json\n {vscode}\n")?; + writeln!( + out, + "Claude.ai and Claude Desktop: Settings → Connectors → Add custom connector → {SERVER_URL}\n" + )?; + writeln!( + out, + "ChatGPT: Settings → Apps & Connectors → Advanced settings → turn on Developer mode, \ + then Create → {SERVER_URL}, No authentication" + )?; + writeln!( + out, + "\nMerge these into existing files rather than replacing them." + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_never_gets_the_key_itself() { + let args = Agent::Codex.add_args( + false, + &auth_for(Agent::Codex, false, &Auth::Key("k".into())), + ); + assert!(args.ends_with(&["--bearer-token-env-var".into(), ENV_KEY.into()])); + assert!(!args.iter().any(|a| a == "k")); + } + + #[test] + fn project_files_reference_the_env_var() { + let auth = auth_for(Agent::Claude, true, &Auth::Key("secret".into())); + let args = Agent::Claude.add_args(true, &auth); + assert!(args.contains(&"API-KEY: ${SOCHAIN_API_KEY}".to_owned())); + assert!(!args.iter().any(|a| a.contains("secret"))); + } + + #[test] + fn keyless_adds_no_header() { + let args = Agent::Claude.add_args(false, &Auth::None); + assert!(!args.iter().any(|a| a == "--header")); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..d11cb5c --- /dev/null +++ b/src/model.rs @@ -0,0 +1,330 @@ +//! The parts of API replies the text output reads. --json output never goes +//! through these types, so a field missing here is never lost there. +//! +//! Optional and nullable fields are `Option` or defaulted, so a reply that +//! leaves something out still prints. scripts/check-api-contract.py checks +//! these against the published OpenAPI description. + +use serde::Deserialize; + +use crate::amount::Amount; + +#[derive(Debug, Deserialize)] +pub struct Price { + pub value: Amount, + pub currency: String, +} + +#[derive(Debug, Deserialize)] +pub struct Height { + pub height: u64, + pub time: i64, +} + +#[derive(Debug, Deserialize)] +pub struct Balance { + pub confirmed: Amount, + #[serde(default)] + pub unconfirmed: Option, + /// Set for a balance at a past height or time. + #[serde(default)] + pub height: Option, + #[serde(default)] + pub time: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddressSummary { + pub txs_sent: u64, + pub txs_received: u64, + pub txs_total: u64, + pub confirmed_balance: Amount, + pub unconfirmed_balance: Amount, + pub confirmed_received: Amount, + #[serde(default)] + pub first_active: Option, + #[serde(default)] + pub last_active: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddressTransactions { + pub transactions: Vec, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub reorged: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddressTransaction { + pub hash: String, + pub balance_change: Amount, + pub time: i64, + pub block: Option, + #[serde(default)] + pub price: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionCounts { + pub sent: u64, + pub received: u64, + pub total: u64, +} + +#[derive(Debug, Deserialize)] +pub struct UnspentOutputs { + pub outputs: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct UnspentOutput { + pub hash: String, + pub index: u32, + pub value: Amount, + pub block: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IsValid { + pub is_valid: bool, +} + +#[derive(Debug, Deserialize)] +pub struct Transaction { + pub hash: String, + pub confirmations: u64, + pub size: u64, + #[serde(default)] + pub vsize: Option, + pub block_height: Option, + pub time: i64, + pub fee: Amount, + #[serde(default)] + pub price: Option, + pub inputs: Vec, + pub outputs: Vec, + /// Zcash: what each shielded pool released into (+) or took from (−) + /// the transparent side. + #[serde(default)] + pub shielded: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct TxInput { + #[serde(default)] + pub address: Option, + #[serde(default)] + pub value: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TxOutput { + #[serde(default)] + pub address: Option, + pub value: Amount, + #[serde(default, rename = "type")] + pub kind: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionStatus { + pub status: String, + pub block_height: Option, + pub confirmations: Option, + pub replaced_by: Option, + pub replaced_by_status: Option, + pub replaced_by_height: Option, + pub replaced_ancestor: Option, + pub removed_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Block { + pub height: u64, + pub hash: String, + pub time: i64, + pub confirmations: u64, + pub num_txs: u64, + pub size: u64, + pub previous_block_hash: Option, + pub next_block_hash: Option, + #[serde(default)] + pub is_orphan: bool, + pub difficulty: String, + pub reward: Amount, + pub fees: Amount, + pub value_out: Amount, + #[serde(default)] + pub price: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LatestBlocks { + pub blocks: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct LatestBlock { + pub height: u64, + pub time: i64, + pub num_txs: u64, + #[serde(default)] + pub miner: Option, + pub reward_and_fees: Amount, +} + +#[derive(Debug, Deserialize)] +pub struct BestBlockHash { + pub hash: String, +} + +#[derive(Debug, Deserialize)] +pub struct NetworkInfo { + pub block_count: u64, + pub best_block_hash: String, + pub hashrate: String, + pub mempool: Mempool, +} + +#[derive(Debug, Deserialize)] +pub struct Mempool { + pub mempool_txs: u64, + pub mempool_size: u64, + #[serde(default)] + pub blocks: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ProjectedBlock { + pub block_num: u32, + pub num_txs: u64, + #[serde(default)] + pub min_fee_rate_precise: Option, + #[serde(default)] + pub median_fee_rate_precise: Option, + #[serde(default)] + pub max_fee_rate_precise: Option, + pub min_fee_rate: f64, + pub median_fee_rate: f64, + pub max_fee_rate: f64, + /// Zcash (ZIP 317): the fee per logical action, and the actions every + /// transaction gets for free. + #[serde(default)] + pub marginal_fee: Option, + #[serde(default)] + pub grace_actions: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PriceData { + pub value: Amount, + pub currency: String, + #[serde(default)] + pub as_of: Option, + #[serde(default)] + pub method: Option, + #[serde(default)] + pub fx_date: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RichList { + pub height: u64, + pub supply: Amount, + pub page: u32, + pub page_size: u32, + pub total_rows: u64, + pub rows: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct RichRow { + pub rank: u64, + pub address: Option, + pub balance: Amount, + pub share_of_supply: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddressRank { + pub address: String, + pub balance: Amount, + pub rank: Option, + #[serde(default)] + pub rank_limit: Option, + pub share_of_supply: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BalanceHistory { + pub interval: String, + pub points: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct BalancePoint { + pub t: i64, + pub v: Amount, + pub received: Amount, + pub sent: Amount, + #[serde(default)] + pub tx_in: Option, + #[serde(default)] + pub tx_out: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddressPoisoning { + pub address: String, + #[serde(default)] + pub building: bool, + pub findings: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PoisoningFinding { + pub sender: String, + pub imitates: String, + #[serde(default)] + pub amount: Option, + #[serde(default)] + pub dust_txid: Option, + #[serde(default)] + pub receiver_paid: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Counterparties { + pub address: String, + #[serde(default)] + pub building: bool, + #[serde(default)] + pub window_blocks: Option, + pub counterparties: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Counterparty { + pub address: String, + pub direction: String, + pub amount: Amount, + pub txid: String, + pub height: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Chart { + pub series: String, + pub interval: String, + pub points: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ChartPoint { + pub t: i64, + pub v: serde_json::Value, + #[serde(default)] + pub height: Option, +} diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..c65c111 --- /dev/null +++ b/src/network.rs @@ -0,0 +1,81 @@ +//! The networks SoChain serves. Codes are case-insensitive on the command line +//! and sent upper-case in API paths. + +use clap::ValueEnum; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "UPPER")] +pub enum Network { + /// Bitcoin + Btc, + /// Litecoin + Ltc, + /// Dogecoin + Doge, + /// Zcash (transparent addresses and transactions) + Zec, + /// Bitcoin testnet4 + Btctest, + /// Litecoin testnet4 + Ltctest, + /// Dogecoin testnet + Dogetest, + /// Zcash testnet + Zectest, +} + +impl Network { + /// The code used in API paths, which is also the unit amounts are shown in. + pub fn code(self) -> &'static str { + match self { + Network::Btc => "BTC", + Network::Ltc => "LTC", + Network::Doge => "DOGE", + Network::Zec => "ZEC", + Network::Btctest => "BTCTEST", + Network::Ltctest => "LTCTEST", + Network::Dogetest => "DOGETEST", + Network::Zectest => "ZECTEST", + } + } + + pub fn name(self) -> &'static str { + match self { + Network::Btc => "Bitcoin", + Network::Ltc => "Litecoin", + Network::Doge => "Dogecoin", + Network::Zec => "Zcash", + Network::Btctest => "Bitcoin testnet4", + Network::Ltctest => "Litecoin testnet4", + Network::Dogetest => "Dogecoin testnet", + Network::Zectest => "Zcash testnet", + } + } + + pub fn is_testnet(self) -> bool { + matches!( + self, + Network::Btctest | Network::Ltctest | Network::Dogetest | Network::Zectest + ) + } + + pub fn is_zcash(self) -> bool { + matches!(self, Network::Zec | Network::Zectest) + } + + /// The unit of `network_info` fee rates. + pub fn fee_rate_unit(self) -> &'static str { + match self { + Network::Btc | Network::Btctest => "sat/vB", + Network::Ltc | Network::Ltctest => "lit/vB", + Network::Doge | Network::Dogetest => "koinu/vB", + Network::Zec | Network::Zectest => "zat/action", + } + } +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} diff --git a/src/sanitize.rs b/src/sanitize.rs new file mode 100644 index 0000000..ca45e76 --- /dev/null +++ b/src/sanitize.rs @@ -0,0 +1,74 @@ +//! Strips control characters from text output, so text that comes from the +//! API (labels, explanations, error messages) cannot move the cursor, change +//! colours or otherwise drive the user's terminal. JSON output needs no +//! filtering: serde_json escapes control characters. + +use std::borrow::Cow; +use std::io::{self, Write}; + +/// Newlines and tabs are layout; every other control character is dropped, +/// including ESC, DEL and the C1 range. +fn allowed(c: char) -> bool { + !c.is_control() || c == '\n' || c == '\t' +} + +pub fn clean(s: &str) -> Cow<'_, str> { + if s.chars().all(allowed) { + Cow::Borrowed(s) + } else { + Cow::Owned(s.chars().filter(|&c| allowed(c)).collect()) + } +} + +/// A writer that passes text through `clean`. +pub struct Sanitized(pub W); + +impl Write for Sanitized { + fn write(&mut self, buf: &[u8]) -> io::Result { + // Formatted output arrives as whole `str` fragments. Anything that is + // not valid UTF-8 is filtered byte by byte instead. + match std::str::from_utf8(buf) { + Ok(s) => self.0.write_all(clean(s).as_bytes())?, + Err(_) => { + let kept: Vec = buf + .iter() + .copied() + .filter(|&b| b >= 0x20 && b != 0x7f || b == b'\n' || b == b'\t') + .collect(); + self.0.write_all(&kept)?; + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_ordinary_text() { + assert!(matches!( + clean("Ƀitcoin · 1,000 BTC\n\tok"), + Cow::Borrowed(_) + )); + } + + #[test] + fn strips_escape_sequences_and_control_characters() { + assert_eq!(clean("\u{1b}[31mred\u{1b}[0m"), "[31mred[0m"); + assert_eq!(clean("a\u{7}b\rc\u{7f}d\u{9b}e"), "abcde"); + } + + #[test] + fn filters_through_the_writer() { + let mut out = Sanitized(Vec::new()); + let label = "\u{1b}]0;title\u{7}pool"; + writeln!(out, "label {label}").unwrap(); + assert_eq!(String::from_utf8(out.0).unwrap(), "label ]0;titlepool\n"); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..3cdcc28 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,191 @@ +//! `sochain schema`: a machine-readable description of every command, for +//! tool-calling agents. Keep it in step with cli.rs and AGENTS.md. + +use serde_json::{Value, json}; + +pub fn schema() -> Value { + json!({ + "name": "sochain", + "version": env!("CARGO_PKG_VERSION"), + "api": "https://sochain.com/api/v3", + "openapi": "https://sochain.com/api/v3/openapi.json", + "terms_of_service": "https://sochain.com/terms", + "networks": ["BTC", "LTC", "DOGE", "ZEC", "BTCTEST", "LTCTEST", "DOGETEST", "ZECTEST"], + "output": { + "text": "plain English on stdout (default)", + "json": "--json or SOCHAIN_JSON=1: the API's reply, unchanged, on stdout", + }, + "envelope": { + "success": { "status": "success", "data": "" }, + "failure": { + "status": "fail", + "data": { + "error_message": "string", + "code": "BAD_INPUT | NOT_FOUND | UNAVAILABLE | RATE_LIMITED | UNAUTHORIZED | OTHER", + "retry_after_secs": "integer, RATE_LIMITED only" + } + }, + "failure_stream": "stderr" + }, + "exit_codes": { + "0": "ok", + "1": "other error (OTHER)", + "2": "bad input: invalid arguments, or rejected by the API (BAD_INPUT)", + "3": "not found (NOT_FOUND)", + "4": "SoChain unreachable or unavailable; retry later (UNAVAILABLE)", + "5": "rate limited after waiting and retrying (RATE_LIMITED)", + "6": "API key missing, invalid, or out of requests (UNAUTHORIZED)" + }, + "conventions": { + "network": "first argument of every lookup, any case", + "amounts": "decimal strings in the network's coin with 8 decimals, e.g. \"0.00112405\"; never floats", + "times": "unix seconds", + "time_inputs": "unix seconds, a UTC date (YYYY-MM-DD) or an RFC 3339 timestamp", + "prices": "objects {value, currency}; USD unless --currency; always 0 on testnets" + }, + "global_flags": { + "--json": "JSON output (env SOCHAIN_JSON)", + "--timeout ": "per-request timeout, default 30 (env SOCHAIN_TIMEOUT)", + "--currency ": "prices in this currency for price, transaction, transactions, block, latest-blocks and the price_usd chart (env SOCHAIN_CURRENCY)" + }, + "auth": { + "key": "optional for every lookup; read from SOCHAIN_API_KEY, else from the file saved by `sochain auth login`", + "header": "API-KEY, sent only to sochain.com" + }, + "commands": commands(), + }) +} + +fn commands() -> Value { + json!({ + "balance
[--height N | --time T]": { + "api": "GET /balance/{network}/{address}", + "data": "confirmed, unconfirmed; with --height or --time: address, height, time, block_hash, confirmed, tip_height" + }, + "address-summary
": { + "aliases": ["address"], + "api": "GET /address_summary/{network}/{address}", + "data": "txs_sent, txs_received, txs_total, confirmed_balance, unconfirmed_balance, confirmed_received, first_active{height,time}, last_active{height,time}" + }, + "transactions
[--page N]": { + "aliases": ["txs"], + "api": "GET /transactions/{network}/{address}/{page}", + "data": "transactions[{hash, value_sent, value_received, balance_change, time, block|null, price}]", + "notes": "10 per page; unconfirmed first, then newest first" + }, + "transactions
[filters]": { + "api": "GET /transactions/{network}/{address}?from_height&to_height&from_time&to_time&direction&unconfirmed&page_size&cursor", + "filters": "--from-height, --to-height, --from-time, --to-time, --direction in|out|both, --unconfirmed include|exclude|only, --page-size 1-100, --cursor, --csv (page size up to 500)", + "data": "transactions[...], next_cursor|null, page_size, from_height, to_height, direction, unconfirmed, tip_height, reorged?, fork_height?", + "notes": "send next_cursor back with --cursor until it is null; a short or empty page with a cursor is not the end; after a reorg rows may repeat, so deduplicate by hash. --csv prints CSV on stdout and the next cursor on stderr" + }, + "transaction-counts
": { + "aliases": ["counts"], + "api": "GET /transaction_counts/{network}/{address}", + "data": "sent, received, total" + }, + "unspent-outputs
[--page N]": { + "aliases": ["utxos", "unspent"], + "api": "GET /unspent_outputs/{network}/{address}/{page}", + "data": "outputs[{hash, index, script, address, value, block|null, tx_hex}]", + "notes": "10 per page, oldest first" + }, + "is-valid-address
": { + "aliases": ["validate"], + "api": "GET /is_valid_address/{network}/{address}", + "data": "is_valid (boolean; an invalid address is not an error)" + }, + "transaction ": { + "aliases": ["tx"], + "api": "GET /transaction/{network}/{hash}", + "data": "hash, confirmations, size, vsize, weight, version, locktime, block_hash, block_height|null, time, inputs_n, outputs_n, inputs_value, outputs_value, fee, price, inputs[{index, value, address, previous_output{hash,index}, ...}], outputs[{index, value, address, type, script, spent, ...}], shielded? (Zcash), mweb labels (Litecoin)" + }, + "transaction-status ": { + "aliases": ["status"], + "api": "GET /transaction_status/{network}/{hash}", + "data": "network, hash, status (pending | mined | replaced | dropped | expired | unknown), block_height, confirmations, replaced_by, replaced_by_status, replaced_by_height, replaced_ancestor, removed_at" + }, + "block ": { + "api": "GET /block/{network}/{hash_or_height} (latest: GET /best_block_hash/{network} first)", + "data": "height, hash, time, confirmations, num_txs, size, weight, version, nonce, bits, difficulty, merkleroot, previous_block_hash, next_block_hash|null, is_orphan, reward, fees, value_out, price, txs[txid], mweb? (Litecoin)" + }, + "latest-blocks ": { + "aliases": ["blocks"], + "api": "GET /latest_blocks_summary/{network}", + "data": "blocks[{height, time, num_txs, size, weight, version, difficulty, price, miner, reward_and_fees}], newest first" + }, + "best-block-hash ": { + "api": "GET /best_block_hash/{network}", + "data": "hash" + }, + "network-info ": { + "aliases": ["network"], + "api": "GET /network_info/{network}", + "data": "block_count, best_block_hash, hashrate, mempool{mempool_txs, mempool_size, updated_at, blocks[{block_num, num_txs, size, weight, min_fee_rate, median_fee_rate, max_fee_rate, *_precise}]}" + }, + "fees ": { + "api": "GET /network_info/{network}", + "data": "network, unit (sat/vB, lit/vB, koinu/vB or zat/action), suggested_fee_rate (the next block's median, a decimal string), mempool_txs, mempool_size, blocks (as in network-info)", + "notes": "unlike other commands, --json reshapes the API reply to the fee fields" + }, + "price [--time T]": { + "api": "GET /price/{network} or GET /price/{network}/{unix_timestamp}", + "data": "value, currency, as_of?, sources?; with --currency other than USD: method (native | usd_fx), fx_rate?, fx_date?", + "notes": "--time takes unix seconds or a date and gives that UTC day's opening price" + }, + "rich-list [--page N] [--page-size 25|50|100]": { + "aliases": ["richlist", "top"], + "api": "GET /rich_list/{network}?page&page_size", + "data": "height, time, supply, page, page_size, total_rows, rows[{rank, address, balance, share_of_supply, tx_count, received, sent, first_seen, last_seen, delta_24h, delta_7d}]", + "notes": "top 1,000 holders; Zcash counts the transparent pool only" + }, + "address-rank
": { + "aliases": ["rank"], + "api": "GET /address_rank/{network}/{address}", + "data": "address, balance, rank|null, rank_limit, share_of_supply" + }, + "balance-history
[--interval day|week|month] [--from T] [--to T] [--last N]": { + "api": "GET /balance_history/{network}/{address}?interval&from&to", + "data": "address, interval, points[{t, height, v, received, sent, tx_in, tx_out}]", + "notes": "--last limits text output only" + }, + "address-poisoning
[--limit N]": { + "aliases": ["poisoning"], + "api": "GET /address_poisoning/{network}/{address}?limit", + "data": "address, building, findings[{sender (the look-alike), imitates, dust_txid, amount, real_txid, real_height, real_amount, receiver_paid, shared_prefix, shared_suffix, sender_kind, sender_txs, height, confirmed}]" + }, + "address-counterparties
[--limit N]": { + "aliases": ["counterparties"], + "api": "GET /address_counterparties/{network}/{address}?limit", + "data": "address, window_blocks, from_height, building, counterparties[{address, direction (sent | received), amount, txid, height}]" + }, + "chart [--interval block|minute|hour|day] [--from N] [--to N] [--last N]": { + "api": "GET /charts/{network}/{series}/{interval}?from&to", + "data": "series, interval, points[{t, v, height?}], oldest first; v is a number, an amount string or an object", + "notes": "series and intervals: https://sochain.com/docs/charts/series; --last limits text output only" + }, + "account": { + "api": "GET /account_info (needs a key)", + "data": "the account object, without the envelope: plan, num_requests_used, key{name, access}, ..." + }, + "auth login [--no-verify]": { + "does": "read a key from a hidden prompt or stdin, check it with GET /account_info, and save it readable only by you", + "data": "saved_to, verified, plan, key_name" + }, + "auth logout": { "data": "removed, path, env_key_set" }, + "auth status": { "data": "key: null, or {source: {kind: env | file, ...}} with account" }, + "skill install [--project | --dir DIR] [--print] [--force]": { + "does": "download the SoChain agent skill from sochain.com, verify its published sha256 digest, and install it for Claude Code (~/.claude/skills) and Codex (~/.agents/skills)", + "source": "https://sochain.com/.well-known/agent-skills/index.json" + }, + "skill status [--project | --dir DIR]": {}, + "skill uninstall [--project | --dir DIR] [--force]": {}, + "mcp install [--use-api-key] [--project] [--print] [--force]": { + "does": "add https://sochain.com/mcp to Codex and Claude Code with their own `mcp add` commands; --print shows the setup for other apps" + }, + "mcp status": {}, + "mcp uninstall [--project] [--force]": {}, + "schema": { "does": "print this description" }, + "guide": { "does": "print the usage guide for scripts and AI agents" } + }) +} diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..309b27a --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,104 @@ +//! Refuses input that looks like a private key or recovery phrase, before it +//! leaves the machine. Lookups only ever need public data (block heights and +//! hashes, transaction ids, addresses), so a secret is never sent anywhere. +//! +//! A bare 64-character hex string is not caught here: it is also what a +//! transaction id or block hash looks like. + +use crate::error::{CliError, Result}; + +pub const WARNING: &str = "that looks like a private key or recovery phrase, so it wasn't \ + looked up; never send one to any website or API"; + +const BASE58: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/// Fails with a bad-input error (exit 2) if `input` looks like a secret. +pub fn refuse_secrets(input: &str) -> Result<()> { + if looks_secret(input) { + Err(CliError::BadInput(WARNING.into())) + } else { + Ok(()) + } +} + +pub fn looks_secret(input: &str) -> bool { + let s = input.trim(); + is_wif(s) || is_extended_private_key(s) || is_recovery_phrase(s) +} + +fn is_base58(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| BASE58.contains(c)) +} + +/// Wallet import format: 51 (uncompressed) or 52 (compressed) base58 +/// characters. Addresses are 26–35, so the lengths never overlap. +/// Prefixes: Bitcoin and Zcash 5/K/L, Litecoin 6/T, Dogecoin 6/Q, +/// testnets 9/c. +fn is_wif(s: &str) -> bool { + matches!(s.len(), 51 | 52) + && is_base58(s) + && s.starts_with(['5', 'K', 'L', '6', 'T', 'Q', '9', 'c']) +} + +/// BIP32 extended private keys (xprv/yprv/zprv, testnet tprv/uprv/vprv, +/// Litecoin Ltpv, Dogecoin dgpv). +fn is_extended_private_key(s: &str) -> bool { + const PREFIXES: [&str; 8] = [ + "xprv", "yprv", "zprv", "tprv", "uprv", "vprv", "Ltpv", "dgpv", + ]; + s.len() >= 100 && is_base58(s) && PREFIXES.iter().any(|p| s.starts_with(p)) +} + +/// 12 to 24 short lowercase words, the shape of a BIP39 recovery phrase. +fn is_recovery_phrase(s: &str) -> bool { + let words: Vec<&str> = s.split_whitespace().collect(); + (12..=24).contains(&words.len()) + && words + .iter() + .all(|w| (3..=8).contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catches_keys_and_phrases() { + // Key-shaped sample strings (standard test vectors and made-up + // examples); none controls any funds. + assert!(looks_secret( + "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" + )); + assert!(looks_secret( + "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn" + )); + assert!(looks_secret( + "6KbBFk8U7sxzfajBnm1JJWqLvdcpd97K9Sf5GPgP2z8A1nR4GZ7" + )); + assert!(looks_secret( + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi" + )); + assert!(looks_secret( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + )); + } + + #[test] + fn lets_public_data_through() { + for public in [ + "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo", + "LQTpS3VaYTjCr4s9Y1t5zbeY26zevf7Fb3", + "ltc1qg82tmpp0lnwnkd0rp9jm8dsgvhtz3wvzkzyl0k", + "DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L", + "t1Rv4exT7bqhZqi2j7xz8bUHDMxwosrjADU", + "2c916c8046224e19f865f288ebc9870fc75f93b5144595b7ec270b31e4863e7f", + "6389673", + "latest", + "what is the bitcoin supply", + ] { + assert!(!looks_secret(public), "{public}"); + } + } +} diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..2a047a4 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,436 @@ +//! `sochain skill`: installs the SoChain agent skill (a SKILL.md published by +//! sochain.com) into AI coding agents' skill folders. +//! +//! The skill is always fetched from the site's agent-skills index and checked +//! against the digest the index lists. A small marker file next to each +//! installed SKILL.md records what was written, so an update never overwrites +//! a file someone has edited (unless `--force`). + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::api::Api; +use crate::error::{CliError, Result}; + +const INDEX_PATH: &str = "/.well-known/agent-skills/index.json"; +const SKILL_NAME: &str = "sochain-blockchain-data"; +const MAX_INDEX_BYTES: usize = 64 * 1024; +const MAX_SKILL_BYTES: usize = 64 * 1024; +const MARKER: &str = ".sochain-skill.json"; + +/// An agent that loads skills from `//SKILL.md`. +#[derive(Debug, Clone, Copy)] +pub struct Agent { + pub id: &'static str, + pub name: &'static str, + /// A folder in the home directory whose presence means the agent is + /// installed. + detect: &'static str, + /// Skills root under the home directory. + user_root: &'static [&'static str], + /// Skills root under a project directory. + project_root: &'static [&'static str], +} + +// Claude Code: code.claude.com/docs/en/skills. Codex reads the cross-agent +// `.agents/skills` folders (see agentskills.io). +pub const AGENTS: &[Agent] = &[ + Agent { + id: "claude", + name: "Claude Code", + detect: ".claude", + user_root: &[".claude", "skills"], + project_root: &[".claude", "skills"], + }, + Agent { + id: "codex", + name: "Codex", + detect: ".codex", + user_root: &[".agents", "skills"], + project_root: &[".agents", "skills"], + }, +]; + +/// Where to install, as chosen on the command line. +pub enum Scope { + /// Every detected agent's user-level skills folder. + User, + /// Every agent's folder inside the current directory. + Project, + /// One explicit skills root. + Dir(PathBuf), +} + +#[derive(Debug, Clone, Serialize)] +pub struct Target { + pub agent: String, + /// The skill folder (containing SKILL.md). + pub path: PathBuf, +} + +pub struct Skill { + pub name: &'static str, + pub digest: String, + pub bytes: Vec, +} + +#[derive(Deserialize)] +struct Index { + skills: Vec, +} + +#[derive(Deserialize)] +struct IndexEntry { + name: String, + #[serde(rename = "type")] + kind: String, + url: String, + digest: String, +} + +#[derive(Serialize, Deserialize)] +struct Marker { + digest: String, + installed_by: String, +} + +/// Fetches the published skill and verifies it against the index digest. +pub fn fetch(api: &Api) -> Result { + let index = api.fetch_site_file(INDEX_PATH, MAX_INDEX_BYTES)?; + let index: Index = serde_json::from_slice(&index) + .map_err(|e| CliError::Other(format!("unexpected agent-skills index: {e}")))?; + let entry = index + .skills + .into_iter() + .find(|s| s.name == SKILL_NAME && s.kind == "skill-md") + .ok_or_else(|| CliError::NotFound("the SoChain skill is not published".into()))?; + let bytes = api.fetch_site_file(&entry.url, MAX_SKILL_BYTES)?; + let digest = sha256(&bytes); + if !entry.digest.eq_ignore_ascii_case(&digest) { + return Err(CliError::Other(format!( + "the downloaded skill does not match its published digest \ + (expected {}, got {digest}); nothing was installed", + entry.digest + ))); + } + let text = std::str::from_utf8(&bytes) + .map_err(|_| CliError::Other("the skill is not valid UTF-8".into()))?; + if front_matter_name(text) != Some(SKILL_NAME) { + return Err(CliError::Other(format!( + "the downloaded skill is not named {SKILL_NAME:?}; nothing was installed" + ))); + } + Ok(Skill { + name: SKILL_NAME, + digest, + bytes, + }) +} + +pub fn sha256(bytes: &[u8]) -> String { + let hash = Sha256::digest(bytes); + let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); + format!("sha256:{hex}") +} + +/// The `name:` field of a SKILL.md's YAML front matter. +fn front_matter_name(text: &str) -> Option<&str> { + let rest = text.strip_prefix("---")?.trim_start_matches(['\r', '\n']); + let (front, _) = rest.split_once("\n---")?; + front.lines().find_map(|line| { + line.strip_prefix("name:") + .map(|v| v.trim().trim_matches(['"', '\''])) + }) +} + +/// The skill folders a command applies to. With `Scope::User`, only agents +/// that appear to be installed are included. +pub fn targets(scope: &Scope) -> Result> { + let folder = |root: PathBuf| root.join(SKILL_NAME); + match scope { + Scope::Dir(dir) => Ok(vec![Target { + agent: "custom".into(), + path: folder(dir.clone()), + }]), + Scope::Project => { + let cwd = std::env::current_dir() + .map_err(|e| CliError::Other(format!("no current directory: {e}")))?; + Ok(AGENTS + .iter() + .map(|a| Target { + agent: a.id.into(), + path: folder(join(&cwd, a.project_root)), + }) + .collect()) + } + Scope::User => { + let home = std::env::home_dir() + .ok_or_else(|| CliError::Other("could not find your home directory".into()))?; + let found: Vec = AGENTS + .iter() + .filter(|a| home.join(a.detect).is_dir()) + .map(|a| Target { + agent: a.id.into(), + path: folder(join(&home, a.user_root)), + }) + .collect(); + if found.is_empty() { + return Err(CliError::NotFound( + "no supported agent found (looked for ~/.claude and ~/.codex); \ + use --dir or --print" + .into(), + )); + } + Ok(found) + } + } +} + +fn join(base: &Path, parts: &[&str]) -> PathBuf { + parts + .iter() + .fold(base.to_path_buf(), |p, part| p.join(part)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum State { + NotInstalled, + UpToDate, + Outdated, + /// SKILL.md differs from what the CLI wrote: someone edited it, or it was + /// not installed by the CLI. + Modified, +} + +impl State { + fn describe(self) -> &'static str { + match self { + State::NotInstalled => "not installed", + State::UpToDate => "up to date", + State::Outdated => "installed, a newer version is published", + State::Modified => "installed, but changed since the CLI wrote it", + } + } +} + +fn state(target: &Target, published: Option<&str>) -> State { + let Ok(current) = fs::read(target.path.join("SKILL.md")) else { + return State::NotInstalled; + }; + let current = sha256(¤t); + let written = fs::read(target.path.join(MARKER)) + .ok() + .and_then(|m| serde_json::from_slice::(&m).ok()) + .map(|m| m.digest); + if written.as_deref() != Some(current.as_str()) { + return State::Modified; + } + match published { + Some(p) if p != current => State::Outdated, + _ => State::UpToDate, + } +} + +#[derive(Serialize)] +struct Outcome { + agent: String, + path: PathBuf, + result: &'static str, +} + +pub fn install( + api: &Api, + scope: &Scope, + force: bool, + json: bool, + out: &mut dyn Write, +) -> Result<()> { + let skill = fetch(api)?; + let mut outcomes = Vec::new(); + let mut skipped = false; + for target in targets(scope)? { + let result = match state(&target, Some(&skill.digest)) { + State::UpToDate => "unchanged", + State::Modified if !force => { + skipped = true; + "skipped: changed since installed (use --force to replace)" + } + before => { + write_skill(&target.path, &skill)?; + if before == State::NotInstalled { + "installed" + } else { + "updated" + } + } + }; + outcomes.push(Outcome { + agent: target.agent, + path: target.path.join("SKILL.md"), + result, + }); + } + if json { + let data = + json!({ "skill": { "name": SKILL_NAME, "digest": skill.digest }, "targets": outcomes }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + } else { + for o in &outcomes { + writeln!( + out, + "{}: {} ({})", + agent_name(&o.agent), + o.result, + o.path.display() + )?; + } + } + if skipped { + return Err(CliError::Other( + "some copies were not updated because they were changed".into(), + )); + } + Ok(()) +} + +fn write_skill(dir: &Path, skill: &Skill) -> Result<()> { + let fail = + |e: std::io::Error| CliError::Other(format!("could not write to {}: {e}", dir.display())); + fs::create_dir_all(dir).map_err(fail)?; + // Write to a temporary file first so a failure never leaves half a file. + let tmp = dir.join(".SKILL.md.tmp"); + fs::write(&tmp, &skill.bytes).map_err(fail)?; + fs::rename(&tmp, dir.join("SKILL.md")).map_err(fail)?; + let marker = Marker { + digest: skill.digest.clone(), + installed_by: concat!("sochain-cli ", env!("CARGO_PKG_VERSION")).into(), + }; + let marker = serde_json::to_vec_pretty(&marker).expect("marker serializes"); + fs::write(dir.join(MARKER), marker).map_err(fail) +} + +pub fn status(api: &Api, scope: &Scope, json: bool, out: &mut dyn Write) -> Result<()> { + // Status still works offline; it just cannot tell whether an update exists. + let published = fetch(api).ok().map(|s| s.digest); + let rows: Vec<_> = targets(scope)? + .into_iter() + .map(|t| { + let st = state(&t, published.as_deref()); + (t, st) + }) + .collect(); + if json { + let targets: Vec<_> = rows + .iter() + .map( + |(t, st)| json!({ "agent": t.agent, "path": t.path.join("SKILL.md"), "state": st }), + ) + .collect(); + let data = json!({ "published_digest": published, "targets": targets }); + writeln!(out, "{:#}", json!({ "status": "success", "data": data }))?; + } else { + for (t, st) in &rows { + writeln!( + out, + "{}: {} ({})", + agent_name(&t.agent), + st.describe(), + t.path.join("SKILL.md").display() + )?; + } + if published.is_none() { + writeln!(out, "(could not reach sochain.com to check for updates)")?; + } + } + Ok(()) +} + +pub fn uninstall(scope: &Scope, force: bool, json: bool, out: &mut dyn Write) -> Result<()> { + let mut outcomes = Vec::new(); + let mut skipped = false; + for target in targets(scope)? { + let result = match state(&target, None) { + State::NotInstalled => "not installed", + State::Modified if !force => { + skipped = true; + "skipped: changed since installed (use --force to remove)" + } + _ => { + let fail = |e: std::io::Error| { + CliError::Other(format!("could not remove {}: {e}", target.path.display())) + }; + fs::remove_file(target.path.join("SKILL.md")).map_err(fail)?; + let _ = fs::remove_file(target.path.join(MARKER)); + // Leave the folder if anything else is in it. + let _ = fs::remove_dir(&target.path); + "removed" + } + }; + outcomes.push(Outcome { + agent: target.agent, + path: target.path.join("SKILL.md"), + result, + }); + } + if json { + writeln!( + out, + "{:#}", + json!({ "status": "success", "data": { "targets": outcomes } }) + )?; + } else { + for o in &outcomes { + writeln!( + out, + "{}: {} ({})", + agent_name(&o.agent), + o.result, + o.path.display() + )?; + } + } + if skipped { + return Err(CliError::Other( + "some copies were not removed because they were changed".into(), + )); + } + Ok(()) +} + +fn agent_name(id: &str) -> &str { + AGENTS + .iter() + .find(|a| a.id == id) + .map(|a| a.name) + .unwrap_or("Folder") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_front_matter_name() { + let md = "---\nname: sochain-blockchain-data\ndescription: x\n---\n# Body\n"; + assert_eq!(front_matter_name(md), Some("sochain-blockchain-data")); + assert_eq!( + front_matter_name("---\nname: \"other\"\n---\n"), + Some("other") + ); + assert_eq!(front_matter_name("# no front matter"), None); + } + + #[test] + fn hashes_like_the_index() { + assert_eq!( + sha256(b"abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..393a30c --- /dev/null +++ b/src/time.rs @@ -0,0 +1,145 @@ +//! Unix-time formatting for human output. Always UTC, so output does not +//! depend on the machine's time zone. + +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// `2026-09-26 14:03:11 UTC` +pub fn utc(ts: i64) -> String { + let days = ts.div_euclid(86_400); + let secs = ts.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!( + "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC", + secs / 3600, + secs % 3600 / 60, + secs % 60 + ) +} + +/// `2026-09-26` +pub fn utc_date(ts: i64) -> String { + utc(ts)[..10].to_owned() +} + +/// `2026-09-26 14:03 UTC` +pub fn utc_minute(ts: i64) -> String { + let full = utc(ts); + format!("{} UTC", &full[..16]) +} + +/// `3 minutes ago`; `just now` if the clock is behind the timestamp. +pub fn ago(ts: i64, now: i64) -> String { + let d = now - ts; + if d < 0 { + return "just now".into(); + } + let (n, unit) = match d { + 0..60 => (d, "second"), + 60..3_600 => (d / 60, "minute"), + 3_600..86_400 => (d / 3_600, "hour"), + 86_400..2_592_000 => (d / 86_400, "day"), + 2_592_000..31_536_000 => (d / 2_592_000, "month"), + _ => (d / 31_536_000, "year"), + }; + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) +} + +/// Unix seconds from `1700000000` or a UTC date `2024-01-31` (its first +/// second). +pub fn parse_unix_or_date(s: &str) -> Option { + let s = s.trim(); + if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) { + return s.parse().ok(); + } + let mut parts = s.splitn(3, '-'); + let (y, m, d) = (parts.next()?, parts.next()?, parts.next()?); + if y.len() != 4 || m.len() != 2 || d.len() != 2 { + return None; + } + let (y, m, d): (i64, u32, u32) = (y.parse().ok()?, m.parse().ok()?, d.parse().ok()?); + if !(1..=12).contains(&m) || d == 0 || d > days_in_month(y, m) { + return None; + } + Some(days_from_civil(y, m, d) * 86_400) +} + +fn days_in_month(y: i64, m: u32) -> u32 { + match m { + 2 if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +/// (year, month, day) to days since 1970-01-01. Howard Hinnant's algorithm. +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let m = i64::from(m); + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Days since 1970-01-01 to (year, month, day), proleptic Gregorian. +/// Howard Hinnant's algorithm. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_utc() { + assert_eq!(utc(0), "1970-01-01 00:00:00 UTC"); + // Bitcoin genesis block. + assert_eq!(utc(1_231_006_505), "2009-01-03 18:15:05 UTC"); + assert_eq!(utc(951_782_400), "2000-02-29 00:00:00 UTC"); + } + + #[test] + fn formats_dates_and_minutes() { + assert_eq!(utc_date(1_790_380_800), "2026-09-26"); + assert_eq!(utc_minute(1_790_481_600), "2026-09-27 04:00 UTC"); + } + + #[test] + fn parses_dates_and_unix_times() { + assert_eq!(parse_unix_or_date("1700000000"), Some(1_700_000_000)); + assert_eq!(parse_unix_or_date("1970-01-01"), Some(0)); + assert_eq!(parse_unix_or_date("2024-02-29"), Some(1_709_164_800)); + assert_eq!(parse_unix_or_date("2023-02-29"), None); + assert_eq!(parse_unix_or_date("2024-1-01"), None); + assert_eq!(parse_unix_or_date("yesterday"), None); + } + + #[test] + fn formats_relative_time() { + assert_eq!(ago(100, 100), "0 seconds ago"); + assert_eq!(ago(0, 1), "1 second ago"); + assert_eq!(ago(0, 180), "3 minutes ago"); + assert_eq!(ago(0, 7_200), "2 hours ago"); + assert_eq!(ago(0, 86_400), "1 day ago"); + assert_eq!(ago(10, 0), "just now"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..e6f0699 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,870 @@ +//! End-to-end tests: the real binary against a local mock of the API. +//! +//! The binary only accepts a different API base in debug builds, so these +//! tests are skipped under `--release`. +#![cfg(debug_assertions)] + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::thread; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::{Value, json}; +use tempfile::TempDir; + +const HASH: &str = "00000000000000000000ae63ac51b71f605ca502649009a2793fd5fce070ce5a"; +const TXID: &str = "5420695c11ddebff09fd2786c16b8481b2187cb750458880ddd9e71cbf0da0e8"; +const ADDR: &str = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"; +const OTHER: &str = "bc1q4599jxvf9hj4m84lfdll26jjzxgrrep33j36t9"; +const KEY: &str = "test-key-0123456789"; + +struct Reply { + status: u16, + headers: Vec<(&'static str, String)>, + content_type: &'static str, + body: String, +} + +fn reply(status: u16, body: Value) -> Reply { + Reply { + status, + headers: vec![], + content_type: "application/json", + body: body.to_string(), + } +} + +fn ok(data: Value) -> Reply { + reply(200, json!({ "status": "success", "data": data })) +} + +fn fail(status: u16, message: &str) -> Reply { + reply( + status, + json!({ "status": "fail", "data": { "error_message": message } }), + ) +} + +fn with_header(mut r: Reply, name: &'static str, value: &str) -> Reply { + r.headers.push((name, value.to_owned())); + r +} + +/// One request as the mock saw it. +#[derive(Debug, Clone)] +struct Seen { + path: String, + /// Header names lower-cased. + headers: Vec<(String, String)>, +} + +impl Seen { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.as_str()) + } +} + +/// Serves `replies` in order, one per request, and records each request. +struct Mock { + base: String, + seen: Arc>>, +} + +impl Mock { + fn paths(&self) -> Vec { + self.seen + .lock() + .unwrap() + .iter() + .map(|s| s.path.clone()) + .collect() + } + + fn request(&self, i: usize) -> Seen { + self.seen.lock().unwrap()[i].clone() + } +} + +fn mock(replies: Vec) -> Mock { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + thread::spawn(move || { + for r in replies { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_owned(); + let mut headers = vec![]; + let mut line = String::new(); + while reader.read_line(&mut line).unwrap() > 2 { + if let Some((k, v)) = line.trim_end().split_once(':') { + headers.push((k.to_ascii_lowercase(), v.trim().to_owned())); + } + line.clear(); + } + log.lock().unwrap().push(Seen { path, headers }); + let mut head = format!( + "HTTP/1.1 {} X\r\ncontent-type: {}\r\ncontent-length: {}\r\nconnection: close\r\n", + r.status, + r.content_type, + r.body.len() + ); + for (k, v) in &r.headers { + head += &format!("{k}: {v}\r\n"); + } + let _ = stream.write_all(format!("{head}\r\n{}", r.body).as_bytes()); + } + }); + Mock { base, seen } +} + +/// The binary pointed at the mock, with a private config folder and none of +/// the caller's SOCHAIN_* settings. +fn sochain(m: &Mock, config: &TempDir) -> Command { + let mut cmd = Command::cargo_bin("sochain").unwrap(); + cmd.env("SOCHAIN_TEST_API_BASE", &m.base) + .env("SOCHAIN_CONFIG_DIR", config.path()) + .env_remove("SOCHAIN_JSON") + .env_remove("SOCHAIN_TIMEOUT") + .env_remove("SOCHAIN_CURRENCY") + .env_remove("SOCHAIN_API_KEY"); + cmd +} + +fn config() -> TempDir { + tempfile::tempdir().unwrap() +} + +fn price() -> Value { + json!({ "value": "80000.00000000", "currency": "USD" }) +} + +fn block() -> Value { + json!({ + "height": 968995, "time": 1_790_598_850, "confirmations": 3, "num_txs": 4866, + "version": 537395200, "nonce": 1971860836, "size": 1642284, "weight": 3994770, + "hash": HASH, "previous_block_hash": HASH, "next_block_hash": null, "is_orphan": false, + "difficulty": "132757073449487.5", "merkleroot": HASH, "reward": "3.12500000", + "fees": "0.01661977", "value_out": "4710.77665999", "bits": "17021ec5", + "txs": [TXID], "price": price() + }) +} + +fn transaction() -> Value { + json!({ + "hash": TXID, "confirmations": 0, "size": 470, "vsize": 308, "weight": 1229, + "version": 2, "locktime": 0, "block_hash": null, "block_height": null, + "time": 1_790_598_850, "inputs_n": 1, "outputs_n": 2, "inputs_value": "0.00112405", + "outputs_value": "0.00111725", "fee": "0.00000680", "price": price(), + "inputs": [{ "index": 0, "value": "0.00112405", "address": OTHER, + "scriptSig": { "hex": "", "asm": "" }, "witness": [], + "previous_output": { "hash": TXID, "index": 1 } }], + "outputs": [ + { "index": 0, "value": "0.00000000", "address": "op_return", "type": "nulldata", + "script": { "hex": "6a", "asm": "OP_RETURN" }, "spent": null, "is_change": null }, + { "index": 1, "value": "0.00111725", "address": ADDR, "type": "witness_v0_keyhash", + "script": { "hex": "00", "asm": "0" }, "spent": null, "is_change": null } + ] + }) +} + +fn history_row(change: &str) -> Value { + json!({ + "hash": TXID, "value_sent": "0.00000000", "value_received": change, + "balance_change": change, "time": 1_790_598_850, "block": 968995, "price": price() + }) +} + +#[test] +fn block_text_output_shows_full_hashes() { + let m = mock(vec![ok(block())]); + sochain(&m, &config()) + .args(["block", "BTC", "968995"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Bitcoin block 968,995 · 4,866 transactions", + )) + .stdout(predicate::str::contains(format!("Hash {HASH}"))) + .stdout(predicate::str::contains("4,710.78 BTC moved")) + .stdout(predicate::str::contains("Price then 1 BTC = $80,000.00")) + .stdout(predicate::str::contains("Next block not mined yet")); + assert_eq!(m.paths(), ["/api/v3/block/BTC/968995"]); +} + +#[test] +fn networks_are_case_insensitive() { + let m = mock(vec![ok( + json!({ "confirmed": "1.00000000", "unconfirmed": "0.00000000" }), + )]); + sochain(&m, &config()) + .args(["balance", "ltc", "LQTpS3VaYTjCr4s9Y1t5zbeY26zevf7Fb3"]) + .assert() + .success() + .stdout("Balance 1 LTC\n"); + assert_eq!( + m.paths(), + ["/api/v3/balance/LTC/LQTpS3VaYTjCr4s9Y1t5zbeY26zevf7Fb3"] + ); +} + +#[test] +fn unknown_networks_are_rejected_locally() { + let m = mock(vec![]); + sochain(&m, &config()) + .args(["balance", "ETH", ADDR, "--json"]) + .assert() + .code(2) + .stdout("") + .stderr(predicate::str::contains("\"code\":\"BAD_INPUT\"")); + assert!(m.paths().is_empty()); +} + +#[test] +fn latest_block_looks_up_the_best_hash_first() { + let m = mock(vec![ok(json!({ "hash": HASH })), ok(block())]); + sochain(&m, &config()) + .args(["block", "BTC", "latest"]) + .assert() + .success(); + assert_eq!( + m.paths(), + [ + "/api/v3/best_block_hash/BTC".to_owned(), + format!("/api/v3/block/BTC/{HASH}") + ] + ); +} + +#[test] +fn json_output_is_the_api_reply_unchanged() { + let m = mock(vec![ok(block())]); + let out = sochain(&m, &config()) + .args(["block", "BTC", "968995", "--json"]) + .output() + .unwrap(); + assert!(out.status.success()); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v, json!({ "status": "success", "data": block() })); + // Field order is kept too. + let text = String::from_utf8(out.stdout).unwrap(); + assert!(text.find("\"status\"").unwrap() < text.find("\"data\"").unwrap()); + assert!(text.find("\"height\"").unwrap() < text.find("\"hash\"").unwrap()); + assert!(out.stderr.is_empty()); +} + +#[test] +fn json_env_var_turns_on_json() { + let m = mock(vec![ok(json!({ "hash": HASH }))]); + let out = sochain(&m, &config()) + .env("SOCHAIN_JSON", "1") + .args(["best-block-hash", "DOGE"]) + .output() + .unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["data"]["hash"], HASH); +} + +#[test] +fn user_input_cannot_escape_the_api_path() { + let m = mock(vec![fail(404, "Unable to fetch block data.")]); + sochain(&m, &config()) + .args(["block", "BTC", "../../x?y=1"]) + .assert() + .code(3); + let paths = m.paths(); + assert!(paths[0].starts_with("/api/v3/block/BTC/"), "{paths:?}"); + assert!(!paths[0].contains('?'), "{paths:?}"); +} + +#[test] +fn not_found_exits_3() { + let m = mock(vec![fail(404, "Transaction data not found.")]); + sochain(&m, &config()) + .args(["transaction", "BTC", TXID]) + .assert() + .code(3) + .stdout("") + .stderr("sochain: Transaction data not found.\n"); +} + +#[test] +fn errors_are_json_on_stderr_with_json() { + let m = mock(vec![fail(400, "Bad direction.")]); + let out = sochain(&m, &config()) + .args(["transactions", "BTC", ADDR, "--direction", "in", "--json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(out.stdout.is_empty()); + let v: Value = serde_json::from_slice(&out.stderr).unwrap(); + assert_eq!( + v, + json!({ "status": "fail", "data": { "error_message": "Bad direction.", "code": "BAD_INPUT" } }) + ); +} + +#[test] +fn unauthorized_exits_6_and_suggests_a_key() { + let m = mock(vec![fail(401, "API Key invalid.")]); + sochain(&m, &config()) + .args(["balance", "BTC", ADDR]) + .assert() + .code(6) + .stderr(predicate::str::contains("API Key invalid.")) + .stderr(predicate::str::contains("sochain auth login")); +} + +#[test] +fn unavailable_exits_4() { + let m = mock(vec![fail(503, "Briefly unavailable.")]); + sochain(&m, &config()) + .args(["network-info", "BTC"]) + .assert() + .code(4) + .stderr("sochain: Briefly unavailable.\n"); + + let mut page = reply(502, json!(null)); + page.content_type = "text/html"; + page.body = "bad gateway".into(); + let m = mock(vec![page]); + sochain(&m, &config()) + .args(["network-info", "BTC"]) + .assert() + .code(4) + .stderr(predicate::str::contains("unavailable right now (HTTP 502)")); +} + +#[test] +fn short_rate_limits_are_waited_out() { + let busy = with_header(fail(429, "Too many requests."), "retry-after", "1"); + let m = mock(vec![busy, ok(json!({ "hash": HASH }))]); + sochain(&m, &config()) + .args(["best-block-hash", "BTC"]) + .assert() + .success() + .stdout(format!("{HASH}\n")) + .stderr(predicate::str::contains("retrying in 1 second")); + assert_eq!(m.paths().len(), 2); +} + +#[test] +fn long_rate_limits_exit_5_at_once() { + let limited = with_header(fail(429, "Daily limit reached."), "retry-after", "3600"); + let m = mock(vec![limited]); + let out = sochain(&m, &config()) + .args(["best-block-hash", "BTC", "--json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(5)); + let v: Value = serde_json::from_slice(&out.stderr).unwrap(); + assert_eq!(v["data"]["code"], "RATE_LIMITED"); + assert_eq!(v["data"]["retry_after_secs"], 3600); + assert!( + v["data"]["error_message"] + .as_str() + .unwrap() + .contains("Daily limit reached") + ); + assert_eq!(m.paths().len(), 1); +} + +#[test] +fn secrets_are_refused_without_a_request() { + let m = mock(vec![]); + sochain(&m, &config()) + .args([ + "balance", + "BTC", + "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("looks like a private key")); + assert!(m.paths().is_empty()); +} + +#[test] +fn the_key_is_sent_in_the_api_key_header() { + let m = mock(vec![ok(json!({ "hash": HASH }))]); + sochain(&m, &config()) + .env("SOCHAIN_API_KEY", KEY) + .args(["best-block-hash", "BTC"]) + .assert() + .success(); + let r = m.request(0); + assert_eq!(r.header("api-key"), Some(KEY)); + let agent = r.header("user-agent").unwrap(); + assert!(agent.starts_with("sochain-cli/"), "{agent}"); +} + +#[test] +fn no_key_header_without_a_key() { + let m = mock(vec![ok(json!({ "hash": HASH }))]); + sochain(&m, &config()) + .args(["best-block-hash", "BTC"]) + .assert() + .success(); + assert_eq!(m.request(0).header("api-key"), None); +} + +fn account() -> Value { + json!({ + "last_login_at": 1_790_000_000, + "plan": { "name": "free", "max_daily_requests": 2500, + "current_period_ends_at": null, "will_renew_at_period_end": false }, + "num_requests_used": { "today": 12, "yesterday": 3400 }, + "key": { "name": "laptop", "access": "read_only" } + }) +} + +#[test] +fn login_checks_and_saves_the_key_then_lookups_use_it() { + let dir = config(); + let m = mock(vec![reply(200, account()), ok(json!({ "hash": HASH }))]); + sochain(&m, &dir) + .args(["auth", "login"]) + .write_stdin(format!("{KEY}\n")) + .assert() + .success() + .stdout(predicate::str::contains( + "Logged in with the key \"laptop\" (free plan).", + )) + .stdout(predicate::str::contains(KEY).not()); + assert_eq!(m.request(0).path, "/api/v3/account_info"); + assert_eq!(m.request(0).header("api-key"), Some(KEY)); + + let file = dir.path().join("credentials.json"); + let saved: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!(saved["api_key"], KEY); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&file).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + sochain(&m, &dir) + .args(["best-block-hash", "BTC"]) + .assert() + .success(); + assert_eq!(m.request(1).header("api-key"), Some(KEY)); + + let m = mock(vec![]); + sochain(&m, &dir) + .args(["auth", "logout"]) + .assert() + .success() + .stdout(predicate::str::contains("Removed the saved key")); + assert!(!file.exists()); +} + +#[test] +fn login_does_not_save_a_rejected_key() { + let dir = config(); + let m = mock(vec![fail(401, "API Key invalid.")]); + sochain(&m, &dir) + .args(["auth", "login"]) + .write_stdin("nope-nope\n") + .assert() + .code(6); + assert!(!dir.path().join("credentials.json").exists()); +} + +#[test] +fn login_rejects_malformed_keys() { + let dir = config(); + let m = mock(vec![]); + sochain(&m, &dir) + .args(["auth", "login", "--no-verify"]) + .write_stdin("two words\n") + .assert() + .code(2); + assert!(m.paths().is_empty()); + assert!(!dir.path().join("credentials.json").exists()); +} + +#[test] +fn account_needs_a_key() { + let m = mock(vec![]); + sochain(&m, &config()) + .arg("account") + .assert() + .code(6) + .stderr(predicate::str::contains("needs an API key")); + assert!(m.paths().is_empty()); +} + +#[test] +fn account_prints_usage_and_json_without_envelope() { + let m = mock(vec![reply(200, account()), reply(200, account())]); + sochain(&m, &config()) + .env("SOCHAIN_API_KEY", KEY) + .arg("account") + .assert() + .success() + .stdout(predicate::str::contains("Plan free")) + .stdout(predicate::str::contains( + "Requests today 12 · yesterday 3,400", + )) + .stdout(predicate::str::contains("Key \"laptop\" (read-only)")); + let out = sochain(&m, &config()) + .env("SOCHAIN_API_KEY", KEY) + .args(["account", "--json"]) + .output() + .unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v, account()); +} + +#[test] +fn address_history_pages_by_number() { + let rows: Vec = (0..10).map(|_| history_row("0.50000000")).collect(); + let m = mock(vec![ok(json!({ "transactions": rows }))]); + sochain(&m, &config()) + .args(["transactions", "BTC", ADDR, "--page", "2"]) + .assert() + .success() + .stdout(predicate::str::contains("+0.5 BTC ($40,000)")) + .stdout(predicate::str::contains(TXID)) + .stdout(predicate::str::contains(format!( + "More: sochain transactions BTC {ADDR} --page 3" + ))); + assert_eq!(m.paths(), [format!("/api/v3/transactions/BTC/{ADDR}/2")]); +} + +#[test] +fn filtered_history_sends_the_filters_and_prints_the_cursor() { + let m = mock(vec![ok(json!({ + "transactions": [history_row("-0.25000000")], + "next_cursor": "abc123", "page_size": 1, "from_height": 0, "to_height": null, + "direction": "out", "unconfirmed": "include", "tip_height": 970000 + }))]); + sochain(&m, &config()) + .args([ + "transactions", + "BTC", + ADDR, + "--from-time", + "2025-01-01", + "--direction", + "out", + "--page-size", + "1", + "--currency", + "eur", + ]) + .assert() + .success() + .stdout(predicate::str::contains("-0.25 BTC")) + .stdout(predicate::str::contains(format!( + "More: sochain transactions BTC {ADDR} --cursor abc123" + ))); + let path = &m.paths()[0]; + assert!( + path.starts_with(&format!("/api/v3/transactions/BTC/{ADDR}?")), + "{path}" + ); + for part in [ + "currency=EUR", + "from_time=2025-01-01", + "direction=out", + "page_size=1", + ] { + assert!(path.contains(part), "{path} lacks {part}"); + } +} + +#[test] +fn csv_history_goes_to_stdout_and_the_cursor_to_stderr() { + let csv = "time_utc,block,txid\n2026-09-28T12:34:10Z,968995,abc\n"; + let mut r = with_header(reply(200, json!(null)), "x-next-cursor", "next1"); + r.content_type = "text/csv"; + r.body = csv.into(); + let m = mock(vec![r]); + sochain(&m, &config()) + .args(["transactions", "BTC", ADDR, "--from-height", "1", "--csv"]) + .assert() + .success() + .stdout(csv) + .stderr(predicate::str::contains("--csv --cursor next1")); + let seen = m.request(0); + assert!(seen.path.contains("format=csv"), "{}", seen.path); + assert_eq!(seen.header("accept"), Some("text/csv")); +} + +#[test] +fn page_and_filters_do_not_mix() { + let m = mock(vec![]); + sochain(&m, &config()) + .args([ + "transactions", + "BTC", + ADDR, + "--page", + "2", + "--direction", + "in", + ]) + .assert() + .code(2); + assert!(m.paths().is_empty()); +} + +#[test] +fn transaction_text_names_inputs_outputs_and_fee() { + let m = mock(vec![ok(transaction())]); + sochain(&m, &config()) + .args(["tx", "BTC", TXID, "--currency", "USD"]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Transaction {TXID} on Bitcoin" + ))) + .stdout(predicate::str::contains( + "Status waiting in the mempool (unconfirmed)", + )) + .stdout(predicate::str::contains(format!( + " {OTHER} 0.00112405 BTC" + ))) + .stdout(predicate::str::contains(" data (OP_RETURN) 0 BTC")) + .stdout(predicate::str::contains( + "Fee 0.0000068 BTC ($0.54) · 470 bytes, 308 vbytes", + )); + assert_eq!( + m.paths(), + [format!("/api/v3/transaction/BTC/{TXID}?currency=USD")] + ); +} + +#[test] +fn transaction_status_explains_replacements() { + let m = mock(vec![ok(json!({ + "network": "BTC", "hash": TXID, "status": "replaced", "block_height": null, + "confirmations": null, "replaced_by": HASH, "replaced_by_status": "mined", + "replaced_by_height": 970001, "replaced_ancestor": null, "removed_at": 1_790_000_000 + }))]); + sochain(&m, &config()) + .args(["status", "BTC", TXID]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Replaced: another transaction spends one of its inputs (a fee bump or double spend): {HASH}, mined in block 970,001." + ))); +} + +#[test] +fn price_at_a_date_uses_its_unix_time() { + let m = mock(vec![ok( + json!({ "value": "42000.00000000", "currency": "USD" }), + )]); + sochain(&m, &config()) + .args(["price", "BTC", "--time", "2024-01-01"]) + .assert() + .success() + .stdout("1 BTC = $42,000.00 (that UTC day's opening price)\n"); + assert_eq!(m.paths(), ["/api/v3/price/BTC/1704067200"]); +} + +fn network_info() -> Value { + json!({ + "block_count": 970710, "best_block_hash": HASH, "hashrate": "925644484990023500000", + "mempool": { "mempool_txs": 56010, "mempool_size": 35987720, "updated_at": 1_791_599_830, + "blocks": [ + { "block_num": 1, "min_fee_rate": 1, "max_fee_rate": 140, "median_fee_rate": 1, + "min_fee_rate_possible": 1, "weight": 3990487, "size": 1578709, "num_txs": 4080, + "min_fee_rate_precise": "0.10", "max_fee_rate_precise": "140.00", + "median_fee_rate_precise": "0.28", "min_fee_rate_possible_precise": "0.10" } + ] } + }) +} + +#[test] +fn fees_show_the_suggested_rate() { + let m = mock(vec![ok(network_info()), ok(network_info())]); + sochain(&m, &config()) + .args(["fees", "BTC"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Suggested fee rate 0.28 sat/vB (the next block's median).", + )); + let out = sochain(&m, &config()) + .args(["fees", "BTC", "--json"]) + .output() + .unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["data"]["unit"], "sat/vB"); + assert_eq!(v["data"]["suggested_fee_rate"], "0.28"); + assert_eq!(m.paths(), ["/api/v3/network_info/BTC"; 2]); +} + +#[test] +fn warning_headers_are_noted_in_text_mode_only() { + let behind = || { + with_header( + ok(json!({ "hash": HASH })), + "warning", + r#"199 - "data is 2 blocks behind""#, + ) + }; + let m = mock(vec![behind(), behind()]); + sochain(&m, &config()) + .args(["best-block-hash", "BTC"]) + .assert() + .success() + .stderr("sochain: note from SoChain: data is 2 blocks behind\n"); + sochain(&m, &config()) + .args(["best-block-hash", "BTC", "--json"]) + .assert() + .success() + .stderr(""); +} + +#[test] +fn text_from_the_api_cannot_drive_the_terminal() { + let m = mock(vec![fail(404, "no \u{1b}[31msuch\u{7} block")]); + sochain(&m, &config()) + .args(["block", "BTC", "1"]) + .assert() + .code(3) + .stderr("sochain: no [31msuch block\n"); +} + +#[test] +fn chart_text_shows_the_latest_points() { + let points: Vec = (0..40) + .map(|i| json!({ "t": 1_790_000_000 + i * 86_400, "v": 1000 + i })) + .collect(); + let m = mock(vec![ok( + json!({ "series": "tx_count", "interval": "day", "points": points }), + )]); + sochain(&m, &config()) + .args([ + "chart", + "DOGE", + "tx_count", + "--last", + "2", + "--from", + "1790000000", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "DOGE tx_count by day, last 2 of 40 points", + )) + .stdout(predicate::str::contains("1,039")); + assert_eq!( + m.paths(), + ["/api/v3/charts/DOGE/tx_count/day?from=1790000000"] + ); +} + +#[test] +fn skill_install_verifies_the_digest_and_sends_no_key() { + let skill = "---\nname: sochain-blockchain-data\ndescription: x\n---\n# SoChain\n"; + let digest = { + use sha2::{Digest, Sha256}; + let h = Sha256::digest(skill.as_bytes()); + format!( + "sha256:{}", + h.iter().map(|b| format!("{b:02x}")).collect::() + ) + }; + let index = json!({ "skills": [{ + "name": "sochain-blockchain-data", "type": "skill-md", + "url": "/.well-known/agent-skills/sochain-blockchain-data/SKILL.md", "digest": digest + }] }); + let mut md = reply(200, json!(null)); + md.content_type = "text/markdown"; + md.body = skill.into(); + let m = mock(vec![reply(200, index), md]); + let dir = tempfile::tempdir().unwrap(); + sochain(&m, &config()) + .env("SOCHAIN_API_KEY", KEY) + .args(["skill", "install", "--dir"]) + .arg(dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("installed")); + let installed = dir.path().join("sochain-blockchain-data").join("SKILL.md"); + assert_eq!(std::fs::read_to_string(installed).unwrap(), skill); + for i in 0..2 { + assert_eq!(m.request(i).header("api-key"), None); + } +} + +#[test] +fn skill_install_refuses_a_mismatched_digest() { + let index = json!({ "skills": [{ + "name": "sochain-blockchain-data", "type": "skill-md", + "url": "/skill.md", "digest": "sha256:00" + }] }); + let mut md = reply(200, json!(null)); + md.body = "---\nname: sochain-blockchain-data\n---\n".into(); + let m = mock(vec![reply(200, index), md]); + let dir = tempfile::tempdir().unwrap(); + sochain(&m, &config()) + .args(["skill", "install", "--dir"]) + .arg(dir.path()) + .assert() + .code(1) + .stderr(predicate::str::contains( + "does not match its published digest", + )); + assert!(!dir.path().join("sochain-blockchain-data").exists()); +} + +#[test] +fn schema_and_guide_need_no_network() { + let m = mock(vec![]); + let out = sochain(&m, &config()).arg("schema").output().unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["name"], "sochain"); + assert!(v["commands"].as_object().unwrap().len() > 20); + sochain(&m, &config()) + .arg("guide") + .assert() + .success() + .stdout(predicate::str::contains("Using `sochain` from scripts")); + assert!(m.paths().is_empty()); +} + +#[test] +fn help_and_version_exit_0() { + let m = mock(vec![]); + sochain(&m, &config()).arg("--help").assert().success(); + sochain(&m, &config()) + .arg("--version") + .assert() + .success() + .stdout(predicate::str::starts_with("sochain ")); +} + +#[test] +fn skill_files_must_be_on_the_same_site() { + let index = json!({ "skills": [{ + "name": "sochain-blockchain-data", "type": "skill-md", + "url": "https://example.com/SKILL.md", "digest": "sha256:00" + }] }); + let m = mock(vec![reply(200, index)]); + sochain(&m, &config()) + .args(["skill", "install", "--print"]) + .assert() + .code(1) + .stderr(predicate::str::contains("not on sochain.com")); + assert_eq!(m.paths().len(), 1); +}