diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml new file mode 100644 index 000000000..4da3f44a6 --- /dev/null +++ b/.github/workflows/ts-ci.yml @@ -0,0 +1,63 @@ +name: TypeScript CI + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ts-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Quality / Node ${{ matrix.node }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + node: + - "22" + - "24" + + defaults: + run: + working-directory: ts + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: ts/package-lock.json + + - name: Install locked dependencies + run: npm ci + + - name: Check architecture boundaries + run: npm run depcruise + + - name: Type-check + run: npm run typecheck + + - name: Run tests + run: npm test + + - name: Build npm bundle + run: npm run build + + - name: Validate npm package contents + run: npm pack --dry-run diff --git a/.github/workflows/ts-standalone-release.yml b/.github/workflows/ts-standalone-release.yml new file mode 100644 index 000000000..fca464bf5 --- /dev/null +++ b/.github/workflows/ts-standalone-release.yml @@ -0,0 +1,228 @@ +name: TypeScript Standalone Artifacts + +on: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +env: + NODE_VERSION: "20" + +jobs: + quality: + name: TypeScript quality gates + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: ts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check architecture boundaries + run: npm run depcruise + + - name: Type-check + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build npm bundle + run: npm run build + + binaries: + name: ${{ matrix.asset }} + needs: quality + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + target: bun-linux-x64-baseline + asset: linux-x64 + executable: wallet-cli + archive: tar.gz + glibc_max: "2.35" + - os: ubuntu-22.04-arm + target: bun-linux-arm64 + asset: linux-arm64 + executable: wallet-cli + archive: tar.gz + glibc_max: "2.35" + - os: macos-15-intel + target: bun-darwin-x64 + asset: macos-x64 + executable: wallet-cli + archive: tar.gz + - os: macos-15 + target: bun-darwin-arm64 + asset: macos-arm64 + executable: wallet-cli + archive: tar.gz + - os: windows-2022 + target: bun-windows-x64-baseline + asset: windows-x64 + executable: wallet-cli.exe + archive: zip + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: ts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build standalone executable + run: >- + npm run build:standalone -- + --target ${{ matrix.target }} + --outfile standalone/${{ matrix.executable }} + + - name: Verify executable and embedded Ledger addon + run: node scripts/verify-standalone.mjs standalone/${{ matrix.executable }} + + - name: Verify Linux ABI baseline + if: runner.os == 'Linux' + shell: bash + env: + MAX_GLIBC: ${{ matrix.glibc_max }} + run: | + set -euo pipefail + executable="standalone/${{ matrix.executable }}" + native_addon="$( + ./node_modules/.bin/bun -e \ + 'import { resolveNodeHidAddon } from "./scripts/standalone/resolve-node-hid-addon.ts"; console.log(resolveNodeHidAddon().nativeAddon)' + )" + host_glibc="$(getconf GNU_LIBC_VERSION | awk '{print $2}')" + + if [[ "${host_glibc}" != "${MAX_GLIBC}" ]]; then + echo "::error::runner glibc ${host_glibc} does not match the supported baseline ${MAX_GLIBC}" + exit 1 + fi + + required_versions="$( + { + readelf --version-info --wide "${executable}" + readelf --version-info --wide "${native_addon}" + } | grep -oE 'GLIBC_[0-9]+(\.[0-9]+)*' | sed 's/^GLIBC_//' | sort -Vu + )" + highest_required="$(printf '%s\n' "${required_versions}" | tail -n 1)" + highest_version="$(printf '%s\n%s\n' "${MAX_GLIBC}" "${highest_required}" | sort -V | tail -n 1)" + + if [[ "${highest_version}" != "${MAX_GLIBC}" ]]; then + echo "::error::Linux artifact requires GLIBC_${highest_required}; maximum allowed is GLIBC_${MAX_GLIBC}" + exit 1 + fi + + dynamic_dependencies="$(readelf --dynamic --wide "${native_addon}")" + if ! grep -Fq 'Shared library: [libudev.so.1]' <<< "${dynamic_dependencies}"; then + echo "::error::Ledger native addon no longer declares its documented libudev.so.1 dependency" + exit 1 + fi + + echo "Verified GLIBC_${highest_required} <= GLIBC_${MAX_GLIBC} and libudev.so.1 dependency" + + - name: Verify macOS code signature + if: runner.os == 'macOS' + run: codesign --verify --verbose standalone/${{ matrix.executable }} + + - name: Package Unix archive + if: matrix.archive == 'tar.gz' + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + package="wallet-cli-${version}-${{ matrix.asset }}" + mkdir -p "standalone-assets/${package}" + cp "standalone/${{ matrix.executable }}" "standalone-assets/${package}/wallet-cli" + cp LICENSE "standalone-assets/${package}/LICENSE" + tar -C standalone-assets -czf "standalone-assets/${package}.tar.gz" "${package}" + rm -r "standalone-assets/${package}" + + - name: Package Windows archive + if: matrix.archive == 'zip' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $version = node -p "require('./package.json').version" + $package = "wallet-cli-$version-${{ matrix.asset }}" + New-Item -ItemType Directory -Force "standalone-assets/$package" | Out-Null + Copy-Item "standalone/${{ matrix.executable }}" "standalone-assets/$package/wallet-cli.exe" + Copy-Item "LICENSE" "standalone-assets/$package/LICENSE" + Compress-Archive -Path "standalone-assets/$package" -DestinationPath "standalone-assets/$package.zip" + Remove-Item -Recurse "standalone-assets/$package" + + - name: Upload platform artifact + uses: actions/upload-artifact@v4 + with: + name: wallet-cli-${{ matrix.asset }} + path: ts/standalone-assets/* + if-no-files-found: error + retention-days: 1 + + artifacts: + name: Publish Actions artifact + needs: binaries + runs-on: ubuntu-24.04 + permissions: + artifact-metadata: write + attestations: write + contents: read + id-token: write + + steps: + - name: Download binaries + uses: actions/download-artifact@v4 + with: + pattern: wallet-cli-* + path: standalone-assets + merge-multiple: true + + - name: Create checksums and metadata + shell: bash + run: | + set -euo pipefail + cd standalone-assets + printf 'commit=%s\nref=%s\nrun_id=%s\n' \ + "${GITHUB_SHA}" "${GITHUB_REF}" "${GITHUB_RUN_ID}" > BUILD_METADATA.txt + sha256sum BUILD_METADATA.txt wallet-cli-* > SHA256SUMS.txt + + - name: Attest standalone archives + uses: actions/attest@v4 + with: + subject-checksums: standalone-assets/SHA256SUMS.txt + + - name: Upload standalone bundle + uses: actions/upload-artifact@v4 + with: + name: wallet-cli-standalone-${{ github.sha }} + path: standalone-assets/* + if-no-files-found: error + retention-days: 30 diff --git a/CLAUDE.md b/CLAUDE.md index c579946e7..30b73a3ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,8 @@ are used only for secret input (create / import / backup / delete). **All `ts/` cd ts npm ci # install npm run build # bundle to dist/ via tsup (bin: wallet-cli -> dist/index.js) +npm run build:standalone -- --outfile standalone/wallet-cli + # Bun (pinned devDependency) standalone executable for the native OS/CPU npm run dev -- # run from source via tsx (e.g. npm run dev -- create --label main) npm test # vitest (tests are co-located as *.test.ts) npm run typecheck # tsc --noEmit diff --git a/README.md b/README.md index 5330fafc6..2f8168d7d 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Both manage the same kind of wallet on the same networks — your address is ide | | [**Java**](java/README.md) — the original | [**TypeScript**](ts/README.md) — agent-first rewrite | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **What it is** | The mature, full-feature reference CLI. | A newer rewrite focused on programmatic integration. | -| **Runtime** | JVM — built with Gradle, run as a `.jar`. Uses the [Trident](https://github.com/tronprotocol/trident) SDK. | [Node.js](https://nodejs.org) **20+**. | -| **Install** | `git clone` + `./gradlew build` (see [Setup](java/README.md#setup)) | `npm install -g @tron-walletcli/wallet-cli` | +| **Runtime** | JVM — built with Gradle, run as a `.jar`. Uses the [Trident](https://github.com/tronprotocol/trident) SDK. | Standalone executable, or [Node.js](https://nodejs.org) **20+** for the npm package. | +| **Install** | `git clone` + `./gradlew build` (see [Setup](java/README.md#setup)) | Download from [TypeScript Standalone Artifacts](https://github.com/tronprotocol/wallet-cli/actions/workflows/ts-standalone-release.yml), or `npm install -g @tron-walletcli/wallet-cli`. | | **How you drive it** | An **interactive prompt only** — start it, then type commands at `>`. | **One-shot subcommands** — `wallet-cli ` from your shell. Interactive prompts only for secret input. | | **Command style** | PascalCase verbs: `RegisterWallet`, `SendCoin`, `GetBalance`. Amounts in **SUN** (1 TRX = 1,000,000 SUN). | Noun-verb subcommands: `create`, `tx send`, `account balance`, with `--flags`. | | **Output for scripts** | Human-readable text. | Stable JSON via `-o json` ([`wallet-cli.result.v1`](ts/docs/machine-interface.md)) + fixed exit codes (`0`/`1`/`2`). | @@ -53,7 +53,7 @@ Full setup (config.conf, connecting to a node), the complete A–Z command list, ## TypeScript — get a taste -Install from npm, then run subcommands directly from your shell: +Download the standalone executable from [TypeScript Standalone Artifacts](https://github.com/tronprotocol/wallet-cli/actions/workflows/ts-standalone-release.yml), or install from npm when Node.js 20+ is already available. Then run subcommands directly from your shell: ```console $ npm install -g @tron-walletcli/wallet-cli @@ -73,4 +73,4 @@ Every command has a reference page, and the JSON contract, exit codes, and agent - **Scripting, CI, or building an AI agent?** → the [TypeScript version](ts/README.md) — the JSON envelope and deterministic exit codes exist for exactly this. - **Working interactively and want the complete TRON toolkit** — GasFree, TRC10 issuance, on-chain DEX/governance, or multi-sig? → the [Java version](java/README.md). -- **Just sending TRX/tokens or staking from your own machine?** → either works; the TypeScript CLI is the lighter install (`npm install -g`, no build step). +- **Just sending TRX/tokens or staking from your own machine?** → either works; the TypeScript CLI can run from a downloaded executable with no runtime installation. diff --git a/java/src/main/java/org/tron/common/utils/Utils.java b/java/src/main/java/org/tron/common/utils/Utils.java index a8f4374f5..812959868 100644 --- a/java/src/main/java/org/tron/common/utils/Utils.java +++ b/java/src/main/java/org/tron/common/utils/Utils.java @@ -138,7 +138,7 @@ public class Utils { public static final int MIN_LENGTH = 2; public static final int MAX_LENGTH = 14; - public static final String VERSION = " v4.10.1"; + public static final String VERSION = " v4.11.0"; public static final String TRANSFER_METHOD_ID = "a9059cbb"; private static SecureRandom random = new SecureRandom(); diff --git a/ts/.dockerignore b/ts/.dockerignore new file mode 100644 index 000000000..3f362227b --- /dev/null +++ b/ts/.dockerignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +standalone/ +standalone-assets/ +release-assets/ +.wallet-cli/ +.private/ +.env +*.log + diff --git a/ts/.gitignore b/ts/.gitignore index a0a00aedd..fa2f25863 100644 --- a/ts/.gitignore +++ b/ts/.gitignore @@ -1,7 +1,9 @@ node_modules/ dist/ +/standalone/ +/release-assets/ *.log .wallet-cli/ .env .private/ -docs/superpowers \ No newline at end of file +docs/superpowers diff --git a/ts/README.md b/ts/README.md index b3300a480..e9dd09228 100644 --- a/ts/README.md +++ b/ts/README.md @@ -8,6 +8,8 @@ The agent-first implementation of wallet-cli, built for automation: every comman - **Encrypted local storage** — software keystores are encrypted on disk; secrets are never passed via argv or environment variables. - **Software and Ledger signing** — sign in software, or on a Ledger device (the private key never leaves the device). - **Covers the main TRON capabilities** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, smart-contract calls and deployment, message and EIP-712/TIP-712 signing, and on-chain queries. +- **Multi-signature end to end** — inspect and replace [account permissions](docs/commands/permission/index.md), then collect signatures either offline by passing a transaction artifact between signers or through the TronLink service, with approval weight and threshold visible at every step. +- **Gas-free transfers** — move USDT and other supported tokens with [no TRX at all](docs/commands/gasfree/index.md), paying the fee in the token itself via the GasFree Open Platform. ## Supported chains @@ -23,27 +25,51 @@ Your address is the same on every network, but balances, tokens, and transaction ## Install -**Prerequisites**: [Node.js](https://nodejs.org) **20 or later** (`node --version` to check). Ledger signing additionally needs a supported Ledger device with the TRON app installed — see the [Ledger guide](docs/guide/ledger.md). +### Standalone executable -```bash -npm install -g @tron-walletcli/wallet-cli -``` +After a change lands on `develop`, download `wallet-cli-standalone-` from the successful [TypeScript Standalone Artifacts workflow](https://github.com/tronprotocol/wallet-cli/actions/workflows/ts-standalone-release.yml). GitHub wraps the artifact in a zip containing all five platform archives, `SHA256SUMS.txt`, and `BUILD_METADATA.txt`. The executables contain the runtime and JavaScript dependencies, including Ledger HID support; Node.js, Bun, and npm are not required. Linux remains dynamically linked to the system ABI: it requires glibc 2.35 or later, and Ledger access requires `libudev.so.1` plus permission to open the device. -Note the scope: the package is `@tron-walletcli/wallet-cli`, not the bare `wallet-cli` name (which is an unrelated third-party package). +| System | Artifact archive | Runtime requirements | +|---|---|---| +| macOS Apple Silicon | `wallet-cli--macos-arm64.tar.gz` | — | +| macOS Intel | `wallet-cli--macos-x64.tar.gz` | — | +| Linux ARM64 | `wallet-cli--linux-arm64.tar.gz` | glibc 2.35+; `libudev.so.1` for Ledger | +| Linux x64 | `wallet-cli--linux-x64.tar.gz` | glibc 2.35+; `libudev.so.1` for Ledger | +| Windows x64 | `wallet-cli--windows-x64.zip` | — | -Verify: +Example for Linux x64: ```bash +unzip "wallet-cli-standalone-.zip" -d wallet-cli-artifacts +cd wallet-cli-artifacts +sha256sum --check --ignore-missing SHA256SUMS.txt +archive="$(find . -maxdepth 1 -name 'wallet-cli-*-linux-x64.tar.gz' -print -quit)" +gh attestation verify "$archive" --repo tronprotocol/wallet-cli +tar -xzf "$archive" +package="${archive#./}" +sudo install -m 0755 "${package%.tar.gz}/wallet-cli" /usr/local/bin/wallet-cli wallet-cli --version ``` -```console - # shows the installed version +On Linux, `ldconfig -p | grep -F libudev.so.1` checks whether the Ledger runtime library is available; Debian and Ubuntu provide it in the `libudev1` package. Device access also needs the Ledger udev rules described in the [Ledger guide](docs/guide/ledger.md). Minimal containers must expose the host's HID device as well as providing the library. + +On Windows, extract the zip and put its directory on `PATH`. On macOS, use the matching archive and install the same way; release binaries carry an ad-hoc code signature, not Apple notarization. If Gatekeeper quarantines a checksum-verified download, remove the quarantine attribute from that executable with `xattr -d com.apple.quarantine /path/to/wallet-cli`. + +Every successful standalone run includes `SHA256SUMS.txt` and GitHub/Sigstore build provenance for each archive. `gh attestation verify` is optional, but the checksum must be verified before installation. Actions retains the combined artifact for 30 days. + +### npm + +The npm package remains available when Node.js **20 or later** is already installed: + +```bash +npm install -g @tron-walletcli/wallet-cli ``` -Upgrade with `npm update -g @tron-walletcli/wallet-cli`; uninstall with `npm uninstall -g @tron-walletcli/wallet-cli`. +The package is `@tron-walletcli/wallet-cli`, not the unrelated bare `wallet-cli` package. Upgrade with `npm update -g @tron-walletcli/wallet-cli`; uninstall with `npm uninstall -g @tron-walletcli/wallet-cli`. -**From source** (contributors, or to run unreleased changes) — additionally requires Git: +Ledger signing additionally needs a supported Ledger device with the TRON app installed — see the [Ledger guide](docs/guide/ledger.md). + +**From source** (contributors, or to run unreleased changes) requires Node.js 20+, npm, and Git: ```bash git clone https://github.com/tronprotocol/wallet-cli.git @@ -52,6 +78,11 @@ npm ci && npm run build npm link # puts `wallet-cli` on your PATH (or run: node dist/index.js) ``` +`npm ci` also installs the exact Bun version pinned in `devDependencies`, so contributors can run +`npm run build:standalone` without a global Bun installation. + +Every update to `develop`, including a merged pull request, runs the standalone workflow. It builds each executable on a runner with the same OS and CPU architecture, exercises the embedded Ledger addon, and stores the five archives plus checksums and build metadata as an Actions artifact. Tag and Release events do not trigger this workflow; maintainers can also start it manually from the Actions page. + **Create your first wallet.** `create` prompts for a master password, then shows the new account: ```bash @@ -106,17 +137,37 @@ Every command — including every subcommand — has a reference page; run `wall | [`derive`](docs/commands/derive.md) | Derive the next HD account from a seed wallet | | [`rename`](docs/commands/rename.md) / [`backup`](docs/commands/backup.md) / [`delete`](docs/commands/delete.md) | Manage accounts (backup writes secret + metadata, mode 0600) | | [`change-password`](docs/commands/change-password.md) | Change the master password (re-encrypt all software keystores) | +| [`address generate`](docs/commands/address/generate.md) | Generate a keypair locally, without adding it to the wallet | ### Transactions | Command | Description | |---|---| | [`tx send`](docs/commands/tx/send.md) | Send native TRX or TRC20/TRC10 tokens | -| [`tx sign`](docs/commands/tx/sign.md) | Sign a transaction built elsewhere, without broadcasting | -| [`tx broadcast`](docs/commands/tx/broadcast.md) | Broadcast a presigned transaction | +| [`tx sign`](docs/commands/tx/sign.md) | Sign transaction JSON, or append a signature to transaction hex, offline | +| [`tx broadcast`](docs/commands/tx/broadcast.md) | Validate and broadcast a presigned JSON or protobuf-hex transaction | +| [`tx approvals`](docs/commands/tx/approvals.md) | Show permission, approvals, accumulated weight, and expiration | +| [`tx multisig`](docs/commands/tx/multisig.md) | Coordinate signature collection through the TronLink service | | [`tx status`](docs/commands/tx/status.md) | Show confirmation status (confirmed / failed / pending / not_found) | | [`tx info`](docs/commands/tx/info.md) | Show full transaction detail + receipt | +### Multi-signature permissions + +| Command | Description | +|---|---| +| [`permission show`](docs/commands/permission/show.md) | Show owner, witness, and active permission groups with decoded operations | +| [`permission update`](docs/commands/permission/update.md) | Replace the complete permission structure — **can permanently lock the account** | + +### Gas-free transfers + +Send tokens with no TRX: sign a TIP-712 authorization and let the GasFree provider broadcast, taking its fee in the transferred token. Needs credentials via [`config`](docs/commands/config.md); unavailable on `tron:shasta`. + +| Command | Description | +|---|---| +| [`gasfree info`](docs/commands/gasfree/info.md) | GasFree address, activation status, nonce, balances, and fees | +| [`gasfree transfer`](docs/commands/gasfree/transfer.md) | Sign and submit a TIP-712 GasFree token transfer | +| [`gasfree trace`](docs/commands/gasfree/trace.md) | Track a submitted transfer to its terminal state | + ### On-chain queries | Command | Description | @@ -125,6 +176,8 @@ Every command — including every subcommand — has a reference page; run `wall | [`account info`](docs/commands/account/info.md) | Show raw account data incl. resources | | [`account history`](docs/commands/account/history.md) | Show transaction history (requires TronGrid) | | [`account portfolio`](docs/commands/account/portfolio.md) | Native + token balances with best-effort USD value | +| [`account activate`](docs/commands/account/activate.md) | Activate a new TRON account, paid for by the active account | +| [`account set`](docs/commands/account/set.md) | Set the one-time on-chain account name or ID | | [`block`](docs/commands/block.md) | Get a block (latest if omitted) | | [`chain params`](docs/commands/chain/params.md) | On-chain governance parameters | | [`chain prices`](docs/commands/chain/prices.md) | Energy/bandwidth unit price and memo fee | @@ -146,8 +199,10 @@ Every command — including every subcommand — has a reference page; run `wall | Command | Description | |---|---| -| [`config`](docs/commands/config.md) | Show / get / set configuration values | +| [`config`](docs/commands/config.md) | Show / get / set configuration values, including TronLink and GasFree credentials (secrets masked) | | [`networks`](docs/commands/networks.md) | List known networks (`tron:mainnet`, `tron:nile`, `tron:shasta`) | +| [`contact`](docs/commands/contact/index.md) | Local recipient address book, usable as `--to ` ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | +| [`encoding convert`](docs/commands/encoding/convert.md) | Convert and validate address, hex, Base64, and Base58Check encodings | ## Documentation map diff --git a/ts/docker/ledger/Dockerfile b/ts/docker/ledger/Dockerfile new file mode 100644 index 000000000..80adbff03 --- /dev/null +++ b/ts/docker/ledger/Dockerfile @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1 + +# Node's bullseye build runs on Ubuntu 22.04 while the actual wallet executable and native HID +# addon are compiled against Ubuntu's glibc 2.35 baseline. +FROM node:20-bullseye-slim AS node-runtime + +FROM ubuntu:22.04 AS build + +ARG DEBIAN_FRONTEND=noninteractive +ARG TARGETARCH + +COPY --from=node-runtime /usr/local/ /usr/local/ + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + g++ \ + libudev-dev \ + libusb-1.0-0-dev \ + make \ + pkg-config \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY LICENSE tsconfig.json ./ +COPY scripts/ scripts/ +COPY src/ src/ + +RUN case "${TARGETARCH}" in \ + amd64) target="bun-linux-x64-baseline" ;; \ + arm64) target="bun-linux-arm64" ;; \ + *) echo "unsupported Docker target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && npm run build:standalone -- \ + --target "${target}" \ + --outfile standalone/wallet-cli \ + && node scripts/verify-standalone.mjs standalone/wallet-cli + +FROM ubuntu:22.04 AS runtime + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + libudev1 \ + libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 wallet \ + && useradd \ + --uid 10001 \ + --gid wallet \ + --home-dir /wallet-data \ + --no-create-home \ + --shell /usr/sbin/nologin \ + wallet \ + && install --directory --owner wallet --group wallet --mode 0700 /wallet-data + +COPY --from=build /workspace/standalone/wallet-cli /usr/local/bin/wallet-cli +COPY --from=build /workspace/LICENSE /usr/share/doc/wallet-cli/LICENSE + +ENV HOME=/wallet-data \ + WALLET_CLI_HOME=/wallet-data + +VOLUME ["/wallet-data"] +USER wallet + +ENTRYPOINT ["/usr/local/bin/wallet-cli"] +CMD ["--help"] + diff --git a/ts/docker/ledger/README.md b/ts/docker/ledger/README.md new file mode 100644 index 000000000..067c6eeae --- /dev/null +++ b/ts/docker/ledger/README.md @@ -0,0 +1,83 @@ +# Ledger transaction test container + +This image builds the Linux standalone executable and its `node-hid` addon on Ubuntu 22.04, then runs it in a minimal Ubuntu 22.04 image with `libudev.so.1`. It is intended for disposable Ledger integration tests, not for holding software-wallet secrets. + +## Build + +Run from the repository root: + +```bash +docker build \ + --file ts/docker/ledger/Dockerfile \ + --tag wallet-cli-ledger:test \ + ts +``` + +The image architecture follows the Docker host: `linux/amd64` builds `bun-linux-x64-baseline`; `linux/arm64` builds `bun-linux-arm64`. + +## Physical Ledger on a Linux Docker host + +Install the official [Ledger udev rules](https://github.com/LedgerHQ/udev-rules), reconnect the Ledger, unlock it, and open the TRON app. The helper detects only `/dev/hidraw*` nodes whose USB vendor ID is Ledger's `2c97`, forwards those nodes and their group IDs, and persists wallet metadata in the `wallet-cli-ledger-data` Docker volume. It does not use `--privileged`. + +```bash +./ts/docker/ledger/run-linux.sh --version + +./ts/docker/ledger/run-linux.sh \ + import ledger --app tron --index 0 --label docker-ledger +``` + +Test signing on Nile without broadcasting. Replace the recipient with an address you control: + +```bash +./ts/docker/ledger/run-linux.sh \ + tx send \ + --network tron:nile \ + --account docker-ledger \ + --to T... \ + --amount 1 \ + --sign-only \ + --output json > ledger-sign-only.json + +jq -e \ + 'select(.schema == "wallet-cli.result.v1" and .success == true) | + .data.mode == "sign-only" and (.data.signed.signature | length) > 0' \ + ledger-sign-only.json +``` + +`--sign-only` exercises RPC transaction construction, HID transport, on-device review, and Ledger signing, but cannot move funds. Broadcast only after inspecting the signed transaction: + +```bash +jq -c \ + 'select(.schema == "wallet-cli.result.v1" and .success == true) | .data.signed' \ + ledger-sign-only.json | \ + ./ts/docker/ledger/run-linux.sh \ + tx broadcast --tx-stdin --network tron:nile --output json --wait +``` + +The broadcast command is a real Nile transaction. Do not replace `tron:nile` with `tron:mainnet` during integration testing. + +## macOS and Windows Docker Desktop + +Docker Desktop does not expose host USB devices directly to Linux containers. A physical Ledger requires Docker Desktop's [USB/IP setup](https://docs.docker.com/desktop/features/usbip/) before a `/dev/hidraw*` node exists inside its Linux VM; the Linux helper cannot perform that host-level attachment. + +For an existing Speculos emulator, the same image can use the project's HTTP transport without USB passthrough: + +```bash +docker run --rm -it \ + --mount type=volume,source=wallet-cli-ledger-data,target=/wallet-data \ + --env SPECULOS_HOST=http://host.docker.internal \ + --env SPECULOS_PORT=5000 \ + wallet-cli-ledger:test \ + import ledger --app tron --index 0 --label speculos +``` + +`SPECULOS_PORT` selects the Speculos transport; without it, wallet-cli uses physical USB/HID. + +## Cleanup + +```bash +docker volume rm wallet-cli-ledger-data +docker image rm wallet-cli-ledger:test +``` + +Deleting the volume removes only container-side wallet metadata. Ledger private keys never leave the device. diff --git a/ts/docker/ledger/run-linux.sh b/ts/docker/ledger/run-linux.sh new file mode 100755 index 000000000..279c84a7c --- /dev/null +++ b/ts/docker/ledger/run-linux.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "error: physical Ledger passthrough requires a Linux Docker host" >&2 + echo "Docker Desktop on macOS/Windows needs USB/IP; see docker/ledger/README.md" >&2 + exit 1 +fi + +if ! command -v udevadm >/dev/null 2>&1; then + echo "error: udevadm is required to identify Ledger hidraw devices" >&2 + exit 1 +fi + +image="${WALLET_CLI_LEDGER_IMAGE:-wallet-cli-ledger:test}" +volume="${WALLET_CLI_LEDGER_VOLUME:-wallet-cli-ledger-data}" +declare -a device_args=() +declare -A added_gids=() + +shopt -s nullglob +for device in /dev/hidraw*; do + properties="$(udevadm info --query=property --name="${device}" 2>/dev/null || true)" + if ! grep -qx 'ID_VENDOR_ID=2c97' <<< "${properties}"; then + continue + fi + + device_args+=(--device "${device}:${device}") + gid="$(stat --format='%g' "${device}")" + if [[ -z "${added_gids[${gid}]:-}" ]]; then + device_args+=(--group-add "${gid}") + added_gids[${gid}]=1 + fi +done + +if (( ${#device_args[@]} == 0 )); then + echo "error: no Ledger hidraw device (USB vendor 2c97) was found" >&2 + echo "unlock the device, open the TRON app, install Ledger udev rules, and reconnect it" >&2 + exit 1 +fi + +declare -a tty_args=(-i) +if [[ -t 0 && -t 1 ]]; then + tty_args=(-it) +fi + +exec docker run --rm "${tty_args[@]}" \ + "${device_args[@]}" \ + --mount "type=volume,source=${volume},target=/wallet-data" \ + "${image}" \ + "$@" + diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md new file mode 100644 index 000000000..75cc0e945 --- /dev/null +++ b/ts/docs/commands/account/activate.md @@ -0,0 +1,103 @@ +# wallet-cli account activate + +Activate a new TRON account. ✍️ + +## Synopsis + +``` +wallet-cli account activate --address
+ [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +A TRON address exists as soon as you generate a key, but it is not *activated* until it appears on +chain. An unactivated address cannot receive TRC10, hold resources, or be the owner of a +transaction. Activation is a funded operation: this command builds an `AccountCreateContract` paid +for by the **active account** (or `--account`) and broadcasts it. + +Preconditions checked before anything is signed: + +- `--address` must be a valid TRON base58 address (`invalid_value`, exit 2). +- The target must not already be active — otherwise `account_already_active` (exit 1). +- The payer's balance must cover the creation fee — otherwise `insufficient_balance` (exit 1), + with `balance` and `required` in the error details. + +Because the fee comes from the chain's current parameters rather than a fixed constant, run +`--dry-run` first to see what it costs right now. + +With `--wait`, the command additionally re-reads the target account after confirmation and fails +with `provider_error` if the node does not report it as active — a confirmed activation that is +invisible to the node is treated as a failure, not a success. + +## Options + +| Option | Description | +|---|---| +| `--address ` | **Required.** Unactivated TRON base58 address to activate | +| `--dry-run` | Build and estimate only — no signature, no broadcast | +| `--sign-only` | Sign and output the complete transaction hex without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex without unlocking | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--password-stdin` | Master password from stdin (software accounts) | + +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Check the current cost before spending anything: + +```bash +wallet-cli account activate --address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 \ + --network tron:nile --dry-run +``` + +Activate and wait for confirmation: + +```bash +echo "$PW" | wallet-cli account activate --address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 \ + --network tron:nile --wait --password-stdin +``` + +```console +✅ Account activated + TxID 4c0a1f4b1e0e0d8a5f0a2c6f9e1d3b7a8c5e2f4d6b8a0c2e4f6a8c0e2f4a6b8c + Block #66,012,345 + Fee 1.1 TRX + Address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + Payer TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + Status success +``` + +An address that is already on chain is refused rather than paid for twice: + +```json +{"schema":"wallet-cli.result.v1","success":false,"command":"account.activate","error":{"code":"account_already_active","message":"TRON account is already active: TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2"}} +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"account-activate"` | +| `stage` | string | `"submitted"` / `"confirmed"` / `"failed"` (absent for `--dry-run`) | +| `mode` | string | `"dry-run"` / `"sign-only"` / `"build-only"` when a mode flag was used | +| `txId` | string | Transaction id | +| `address` | string | The address that was activated | +| `payer` | string | Address that paid the creation fee | +| `blockNumber`, `feeSun` | number \| string | Present after `--wait` | + +## Exit status + +`0` · `1` execution failure (`account_already_active`, `insufficient_balance`, `provider_error`, +`auth_failed`) · `2` usage error (`invalid_value`, conflicting mode flags). + +## See also + +[`address generate`](../address/generate.md) — make a keypair to activate · +[`account info`](info.md) · [`tx send`](../tx/send.md) — a transfer to a fresh address also +activates it diff --git a/ts/docs/commands/account/index.md b/ts/docs/commands/account/index.md index fc12c1e69..f306b408b 100644 --- a/ts/docs/commands/account/index.md +++ b/ts/docs/commands/account/index.md @@ -12,6 +12,8 @@ All subcommands read the chain for the **active account** by default; override w ## Subcommands +Read-only: + | Command | Description | Data source | |---|---|---| | [`account balance`](balance.md) | Native balance (TRX/SUN) | node RPC | @@ -19,6 +21,16 @@ All subcommands read the chain for the **active account** by default; override w | [`account history`](history.md) | Transaction history | **TronGrid required** | | [`account portfolio`](portfolio.md) | Native + token balances, best-effort USD | node RPC + price source | +Broadcasting (✍️) — these change on-chain state and cost fees: + +| Command | Description | +|---|---| +| [`account activate`](activate.md) | Activate a new TRON account, paid for by the active account | +| [`account set`](set.md) | Set the one-time on-chain account name or ID | + +Both write-side commands are effectively one-shot: an account can only be activated once, and the +on-chain name and ID can each be set once. Run them with `--dry-run` first. + ## See also [`list`](../list.md) — local accounts (no chain access) · [Networks & resources](../../concepts/networks.md) diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md new file mode 100644 index 000000000..38110238f --- /dev/null +++ b/ts/docs/commands/account/set.md @@ -0,0 +1,94 @@ +# wallet-cli account set + +Set the one-time on-chain account name or ID. ✍️ + +## Synopsis + +``` +wallet-cli account set (--name | --id ) + [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +TRON accounts carry two optional on-chain text fields: + +| Field | Contract | Constraint | +|---|---|---| +| `--name` | `AccountUpdateContract` | 1–32 UTF-8 bytes | +| `--id` | `SetAccountIdContract` | 8–32 UTF-8 bytes, **unique across the chain** | + +Both are effectively **immutable** — the chain accepts them once, and a second attempt is rejected. +Treat this as a one-way decision and confirm the value with `--dry-run` before broadcasting. + +Exactly one of `--name` / `--id` per invocation (`invalid_option`, exit 2). The target account must +already be activated, otherwise the command fails before signing. + +These fields are on-chain metadata and are unrelated to the local wallet label set by +[`rename`](../rename.md), which never touches the chain. + +After a confirmed broadcast the command re-reads the account and fails with `provider_error` if the +stored value does not match what was submitted. + +## Options + +| Option | Description | +|---|---| +| `--name ` | One-time on-chain account name (1–32 UTF-8 bytes) | +| `--id ` | One-time unique account ID (8–32 UTF-8 bytes) | +| `--dry-run` | Build and estimate only — no signature, no broadcast | +| `--sign-only` | Sign and output the complete transaction hex without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex without unlocking | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--password-stdin` | Master password from stdin (software accounts) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli account set --name alice --network tron:nile --dry-run +``` + +```bash +echo "$PW" | wallet-cli account set --id alice-001 --network tron:nile \ + --wait --password-stdin +``` + +```console +✅ On-chain id set + TxID 9b2f...c1d4 + Block #66,012,401 + Address TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + ID alice-001 + Status success +``` + +Passing both selectors is a usage error: + +```console +error [invalid_option]: provide exactly one of --name or --id +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"account-set"` | +| `field` | string | `"name"` or `"id"` | +| `value` | string | The value written on chain | +| `address` | string | Account that was updated | +| `stage`, `txId`, `blockNumber`, `feeSun` | — | Standard broadcast receipt fields | + +## Exit status + +`0` · `1` execution failure (account not activated, chain rejected an already-set field, +`provider_error`, `auth_failed`) · `2` usage error (neither/both of `--name` / `--id`, conflicting +mode flags). + +## See also + +[`rename`](../rename.md) — local label only · [`account info`](info.md) · +[`account activate`](activate.md) diff --git a/ts/docs/commands/address/generate.md b/ts/docs/commands/address/generate.md new file mode 100644 index 000000000..f98665818 --- /dev/null +++ b/ts/docs/commands/address/generate.md @@ -0,0 +1,96 @@ +# wallet-cli address generate + +Generate a random TRON/EVM keypair locally, without adding it to the wallet. + +## Synopsis + +``` +wallet-cli address generate [--out ] [--print-secret] [options] +``` + +## Description + +Generates a secp256k1 keypair offline and derives both address encodings from the same public key: +the TRON base58 address and the EVM `0x` address. No network access, no wallet unlock, no keystore +write. + +**The private key is not printed by default.** It is written to an exclusively-created `0600` file: + +- `--out ` — write there. An existing file is **never** overwritten; the create is exclusive, + so a collision fails rather than clobbering a key you already have. +- omitted — write to `/generated/keypair-`. + +`--print-secret` prints the key to stdout instead of writing a file. That puts the secret in your +terminal scrollback and, in `-o json`, in whatever consumes the envelope — use it only on an +offline machine. + +The generated key is **not** in the wallet: `list`, `use`, and every signing command remain unaware +of it. Import it with [`import private-key`](../import/private-key.md) if you want to sign with it. + +## Options + +| Option | Description | +|---|---| +| `--out ` | Exclusive `0600` output path; existing files are never overwritten | +| `--print-secret` | Print the private key instead of writing it — use only offline | + +Plus the [global options](../index.md#global-options-every-command). `--network` and `--account` +do not apply: the command is entirely local. + +## Examples + +```bash +wallet-cli address generate +``` + +```console +✅ Keypair generated (NOT stored in the wallet) + TRON address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + EVM address 0x0C5f589E3C99Cc365ffb8af588241921f764dF66 + Private key written to /Users/you/.wallet-cli/generated/keypair-TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + +! To sign with this key, import it: wallet-cli import private-key +``` + +Write to a specific location: + +```bash +wallet-cli address generate --out /secure/usb/key.json +``` + +The secret file is JSON: + +```json +{"version":1,"privateKey":"…","publicKey":"…","tron":"TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2","evm":"0x0C5f589E3C99Cc365ffb8af588241921f764dF66"} +``` + +JSON output — note that `privateKey` is absent unless `--print-secret` was given: + +```bash +wallet-cli address generate -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"address.generate","data":{"tron":"TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2","evm":"0x0C5f589E3C99Cc365ffb8af588241921f764dF66","secretFile":"/Users/you/.wallet-cli/generated/keypair-TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2"},"meta":{"durationMs":21,"warnings":[]}} +``` + +## Output + +`data` is a local result — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `tron` | string | TRON base58 address | +| `evm` | string | EIP-55 checksummed EVM address from the same key | +| `secretFile` | string | Path the private key was written to (omitted with `--print-secret`) | +| `privateKey` | string | Raw private key hex — **only** with `--print-secret` | + +## Exit status + +`0` · `1` execution failure — `entropy_failure`, `file_exists` (refusing to overwrite an existing +keypair file), `io_error` · `2` usage error. + +## See also + +[`import private-key`](../import/private-key.md) · [`create`](../create.md) · +[`encoding convert`](../encoding/convert.md) · [Security model](../../concepts/security.md) diff --git a/ts/docs/commands/address/index.md b/ts/docs/commands/address/index.md new file mode 100644 index 000000000..906d589c9 --- /dev/null +++ b/ts/docs/commands/address/index.md @@ -0,0 +1,30 @@ +# wallet-cli address + +Local keypair utilities that never touch the wallet or the network. + +## Synopsis + +``` +wallet-cli address COMMAND +``` + +## Subcommands + +| Command | Description | Network | +|---|---|---| +| [`address generate`](generate.md) | Generate a random TRON/EVM keypair locally | none | + +## Why it is separate from `create` + +[`create`](../create.md) and [`import`](../import/index.md) produce accounts the wallet **owns** — +encrypted on disk, unlockable, signable. `address generate` produces a bare keypair that the wallet +does not know about: nothing is added to the keystore, and the CLI cannot sign with it afterwards. + +Use it when you need a key for something else (a test fixture, a cold address, a key you will hand +to another system). To sign with it here, import it afterwards with +[`import private-key`](../import/private-key.md). + +## See also + +[`create`](../create.md) · [`import private-key`](../import/private-key.md) · +[`encoding convert`](../encoding/convert.md) · [Security model](../../concepts/security.md) diff --git a/ts/docs/commands/config.md b/ts/docs/commands/config.md index 48c3469d3..0a576927e 100644 --- a/ts/docs/commands/config.md +++ b/ts/docs/commands/config.md @@ -29,9 +29,31 @@ Known keys: | `waitTimeoutMs` | integer ms ≥ 0 | `60000` | Default `--wait` polling cap for broadcast commands | | `networks` | — | — | Known networks (read-only list) | +Service credentials — all optional, all writable, and only needed by the commands that use them: + +| Key | Used by | Meaning | +|---|---|---| +| `tronlinkSecretId` | [`tx multisig`](tx/multisig.md) | TronLink multi-sign service secret id | +| `tronlinkSecretKey` | [`tx multisig`](tx/multisig.md) | TronLink service secret key — **masked on read** | +| `tronlinkChannel` | [`tx multisig`](tx/multisig.md) | TronLink service channel | +| `gasfreeApiKey` | [`gasfree`](gasfree/index.md) | GasFree Open Platform API key | +| `gasfreeApiSecret` | [`gasfree`](gasfree/index.md) | GasFree API secret — **masked on read** | + +Each must be 1–256 characters with no control characters. + +**Secrets are masked, never echoed.** `tronlinkSecretKey` and `gasfreeApiSecret` read back as +`********`, and setting one returns `"value":"********","input":"********"` rather than the value +you typed — so a config write is safe to log. An unset secret reads as absent, not as `********`, +which is how you tell "not configured" from "configured". + +These are the only wallet-cli settings that hold credentials, and unlike wallet secrets they are +stored in the config document rather than the encrypted keystore — they authenticate you to a +third-party service, they do not control funds. + Precedence for a value that has both a flag and a config key (highest first): command-line flag > config value > built-in default — e.g. `--timeout` > config `timeoutMs` > built-in 60000. -An invalid value returns `invalid_value` (exit 2). +An invalid value returns `invalid_value` (exit 2). `networks` is read-only — writing it fails with +`networks is read-only` (`invalid_value`, exit 2). ## Examples @@ -77,13 +99,39 @@ wallet-cli config timeoutMs 120000 -o json {"schema":"wallet-cli.result.v1","success":true,"command":"config","data":{"key":"timeoutMs","value":120000,"input":"120000"},"meta":{"durationMs":3,"warnings":[]}} ``` +Configure a service credential — note that the value never appears in the output: + +```bash +wallet-cli config gasfreeApiSecret "$SECRET" -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"config","data":{"key":"gasfreeApiSecret","value":"********","input":"********"},"meta":{"durationMs":37,"warnings":[]}} +``` + +```bash +wallet-cli config +``` + +```console +defaultNetwork tron:mainnet +defaultOutput text +timeoutMs 60000 +waitTimeoutMs 60000 +networks tron:mainnet, tron:nile, tron:shasta +gasfreeApiSecret ******** +``` + +Keys that have never been set are omitted from the listing entirely — that absence is how you tell +an unconfigured credential from a configured one. + ## Output `data` varies by mode. Local command — no `chain` block. | Mode | `data` fields | |---|---| -| show all (no args) | one field per key: `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`, `networks` (array of network ids) | +| show all (no args) | one field per set key: `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`, `networks` (array of network ids), plus any configured `tronlink*` / `gasfree*` credentials, with secrets as `********` | | read (``) | `key`, `value` | | set (` `) | `key`, `value`, `input` (the raw string as typed) | diff --git a/ts/docs/commands/contact/add.md b/ts/docs/commands/contact/add.md new file mode 100644 index 000000000..1d5294be7 --- /dev/null +++ b/ts/docs/commands/contact/add.md @@ -0,0 +1,83 @@ +# wallet-cli contact add + +Add a recipient to the local address book. + +## Synopsis + +``` +wallet-cli contact add
[--note ] [options] +``` + +## Description + +Stores a name → address mapping locally. The address is validated as TRON Base58Check **at this +point**, so a typo fails here rather than on a later transfer: + +```console +error [invalid_value]: address is not a valid TRON address +``` + +After adding, the name is accepted by [`tx send --to`](../tx/send.md) and +[`gasfree transfer --to`](../gasfree/transfer.md). + +Names must not themselves look like a TRON address — a name that would be ambiguous with an +address is refused, so `--to ` can never be silently reinterpreted. + +Adding a name that already exists is an error (`already_exists`), not an update. Lookups are +case-insensitive and Unicode-normalized (NFKC), so `Alice` and `alice` are the same contact — to +repoint a name, [`contact remove`](remove.md) it first. + +No network access and no wallet unlock. + +## Arguments + +- `name` — contact name, 1–64 safe characters; must not resemble a TRON address (positional) +- `address` — TRON base58 address (positional) + +## Options + +| Option | Description | +|---|---| +| `--note ` | Free-form note, at most 128 safe characters | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contact add alice TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t --note "Alice mainnet" +``` + +```console +✅ Contact added + Name alice + Address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + Note Alice mainnet +``` + +Then send by name: + +```bash +echo "$PW" | wallet-cli tx send --to alice --amount 1 --network tron:nile --password-stdin +``` + +## Output + +`data` is the stored contact. Local command — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `name` | string | Contact name as stored | +| `address` | string | Validated TRON base58 address | +| `note` | string \| null | The note, or `null` | +| `family` | string | Chain family — `tron` | + +## Exit status + +`0` · `1` execution failure (`insecure_permissions`, `encoding_error` — see +[`contact list`](list.md#storage)) · `2` usage error — `invalid_value` (malformed address, bad +name/note), `already_exists`, `limit_exceeded`. + +## See also + +[`contact list`](list.md) · [`contact remove`](remove.md) · [`tx send`](../tx/send.md) diff --git a/ts/docs/commands/contact/index.md b/ts/docs/commands/contact/index.md new file mode 100644 index 000000000..527d12a00 --- /dev/null +++ b/ts/docs/commands/contact/index.md @@ -0,0 +1,55 @@ +# wallet-cli contact + +A local address book of recipients. + +## Synopsis + +``` +wallet-cli contact COMMAND +``` + +## Subcommands + +| Command | Description | Network | +|---|---|---| +| [`contact add`](add.md) | Add a recipient | none | +| [`contact list`](list.md) | List recipients | none | +| [`contact remove`](remove.md) | Remove a recipient | none | + +## What a contact is for + +A contact maps a **name** to a validated TRON base58 address. Once stored, the name can be used +anywhere a recipient is expected: + +```bash +wallet-cli tx send --to alice --amount 1 --network tron:nile +wallet-cli gasfree transfer --to alice --amount 25 +``` + +The point is not convenience but transcription safety: the address is checksum-validated **once**, +when you add it, instead of being re-typed (and re-risked) on every transfer. Receipts show both, +so the resolution stays auditable — `To alice (TR7NHq…)`. + +## Storage and trust + +The address book lives in `contacts.json` under the wallet home. It is **not encrypted** — it holds +no secrets, only public addresses — but it is treated as security-relevant input: the file must be a +regular file (not a symlink), owned by you, with mode `0600`, and every entry is re-validated on +read. A file that fails those checks is rejected (`insecure_permissions` / `encoding_error`) rather +than trusted. + +That protects the file, not the decision. A contact is local data, not authority: anything that can +write your wallet directory can change where a name points. Verify the address printed on the +receipt before confirming a large transfer, exactly as you would a pasted address. + +Names are matched by a case-insensitive, NFKC-normalized key, so `Alice` and `alice` are the same +contact, and a name that resembles a TRON address is refused outright. The book holds at most +10,000 entries per family and is kept sorted by name. + +Contacts are chain-family scoped (`tron` today) and are **not** per-network: the same address is +valid on mainnet, Nile, and Shasta. + +## See also + +[`token add`](../token/add.md) — the equivalent address book for tokens · +[`tx send`](../tx/send.md) · [`gasfree transfer`](../gasfree/transfer.md) diff --git a/ts/docs/commands/contact/list.md b/ts/docs/commands/contact/list.md new file mode 100644 index 000000000..e43a09571 --- /dev/null +++ b/ts/docs/commands/contact/list.md @@ -0,0 +1,71 @@ +# wallet-cli contact list + +List every recipient in the local address book. + +## Synopsis + +``` +wallet-cli contact list [options] +``` + +## Description + +Prints all stored contacts, sorted by name. Local only — no network access, no wallet unlock. + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contact list +``` + +```console +| Name | Address | Note | +| ----- | ---------------------------------- | ------------- | +| alice | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t | Alice mainnet | +``` + +```bash +wallet-cli contact list -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contact.list","data":{"contacts":[{"name":"alice","address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","note":"Alice mainnet","family":"tron"}]},"meta":{"durationMs":14,"warnings":[]}} +``` + +An empty address book is a success, not an error — `data.contacts` is `[]`. + +## Output + +`data` is a local result — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `contacts` | array | Stored contacts, sorted by name | +| `contacts[].name` | string | Contact name | +| `contacts[].address` | string | TRON base58 address | +| `contacts[].note` | string \| null | The note, or `null` | +| `contacts[].family` | string | Chain family — `tron` | + +## Storage + +Contacts are kept in `contacts.json` under the wallet home. The file is not encrypted, but it must +be a regular file owned by you with mode `0600`, and each entry is re-validated on every read. +A file that fails those checks fails the command: + +| Code | Cause | +|---|---| +| `insecure_permissions` | Symlink, wrong owner, or mode other than `0600` | +| `encoding_error` | Not valid JSON, wrong schema, duplicate names, or larger than 4 MiB | + +## Exit status + +`0` · `1` execution failure (`insecure_permissions`, `encoding_error`) · `2` usage error. + +## See also + +[`contact add`](add.md) · [`contact remove`](remove.md) · [`list`](../list.md) — wallet accounts, +not recipients diff --git a/ts/docs/commands/contact/remove.md b/ts/docs/commands/contact/remove.md new file mode 100644 index 000000000..44ee9c491 --- /dev/null +++ b/ts/docs/commands/contact/remove.md @@ -0,0 +1,66 @@ +# wallet-cli contact remove + +Remove one recipient from the local address book. + +## Synopsis + +``` +wallet-cli contact remove [options] +``` + +## Description + +Deletes a single contact. This is a purely local edit — it changes **no on-chain state**, moves no +funds, and does not affect any transaction that already used the name. + +The lookup is case-insensitive, so the name may be given in any casing. An unknown name fails with +`not_found` rather than silently succeeding. + +Use this to repoint a name at a different address: remove it, then +[`contact add`](add.md) it again — [`contact add`](add.md) refuses to overwrite an existing entry. + +## Arguments + +- `name` — contact name to remove (positional) + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contact remove alice +``` + +```console +✅ Contact removed + Name alice + Address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +``` + +Removing a name that is not there: + +```console +error [not_found]: contact not found: alice +``` + +## Output + +`data` is the removed contact — the address is echoed so you can confirm what was dropped. + +| Field | Type | Meaning | +|---|---|---| +| `name` | string | Removed contact name | +| `address` | string | Address it pointed to | +| `note` | string \| null | The note, or `null` | +| `family` | string | Chain family — `tron` | + +## Exit status + +`0` · `1` execution failure (`insecure_permissions`, `encoding_error`) · `2` usage error +(`not_found`, missing positional). + +## See also + +[`contact add`](add.md) · [`contact list`](list.md) diff --git a/ts/docs/commands/current.md b/ts/docs/commands/current.md index f2dbddb0c..65a670cb5 100644 --- a/ts/docs/commands/current.md +++ b/ts/docs/commands/current.md @@ -5,12 +5,36 @@ Show the current (active) account. ## Synopsis ``` -wallet-cli current [options] +wallet-cli current [--qr] [options] ``` +## Description + +Shows the selected account entirely locally — no unlock, no network access. By default that is the +persisted **active** account; `--account ` inspects a different one +without changing the active selection (use [`use`](use.md) for that). + +`--qr` appends a scannable TRON **receive-address** QR code, encoding exactly the address and +nothing else — no amount, no memo, no URI scheme. The full address is printed underneath so you can +verify by eye what the code contains before anyone scans it. + +`--qr` applies to text output only: + +- In `-o json` it is ignored — the envelope stays a stable data frame rather than gaining terminal + art. +- If the terminal is non-interactive or too narrow to render a complete code, the command warns + (`terminal is non-interactive or too narrow for a complete QR code; showing the full address + only`) and prints the address alone. A **partial** QR is never shown — a truncated code could + scan as a different address. +- An account with no TRON address fails with `invalid_value`. + ## Options -[Global options](index.md) only. +| Option | Description | +|---|---| +| `--qr` | Render a terminal receive QR for the selected TRON address (text output, interactive TTY) | + +Plus the [global options](index.md). ## Examples @@ -31,6 +55,31 @@ wallet-cli current -o json {"schema":"wallet-cli.result.v1","success":true,"command":"current","data":{"accountId":"wlt_758891fa.1","label":"main-1","type":"seed","index":1,"active":true,"addresses":{"tron":"TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax"},"seedId":"wlt_758891fa"},"meta":{"durationMs":13,"warnings":[]}} ``` +Show a receive QR to be scanned by a phone wallet: + +```bash +wallet-cli current --qr +``` + +```console +Active account: main-1 + TRON address TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax + +█▀▀▀▀▀█ ▀▄█ ▄▀ █▀▀▀▀▀█ +█ ███ █ █▄▀ ▄█ █ ███ █ +█ ▀▀▀ █ ▀ █▄▄▀ █ ▀▀▀ █ +▀▀▀▀▀▀▀ █▄█▄▀ ▀▀▀▀▀▀▀ + +Receive address TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax +``` + +Inspect another account without switching to it — the heading reads `Selected account:` rather than +`Active account:`, because it is not the active one: + +```bash +wallet-cli current --qr --account treasury +``` + With no active account yet, it fails with `missing_wallet_address` (exit 1): ```bash @@ -51,7 +100,7 @@ error [missing_wallet_address]: no active account; import one first | `label` | string | Account label | | `type` | string | `seed` / `privateKey` / `watch` / `ledger` | | `index` | number \| null | HD derivation index; `null` for non-HD accounts | -| `active` | boolean | Always `true` | +| `active` | boolean | Whether this is the active account — `false` when `--account` selected another | | `addresses.tron` | string | Base58 TRON address | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `family` | string | Chain family, e.g. `tron` (`watch` accounts only) | diff --git a/ts/docs/commands/encoding/convert.md b/ts/docs/commands/encoding/convert.md new file mode 100644 index 000000000..c274b2bf6 --- /dev/null +++ b/ts/docs/commands/encoding/convert.md @@ -0,0 +1,155 @@ +# wallet-cli encoding convert + +Convert and validate address, hex, Base64, and Base58Check encodings. + +## Synopsis + +``` +wallet-cli encoding convert [options] +wallet-cli encoding convert --input [options] +``` + +## Description + +Auto-detects what you passed and prints every equivalent form. Runs entirely locally: no network, +no wallet, no unlock. + +Detection is by shape, and falls into two families: + +**Address / public key** — prints the TRON base58, TRON hex, and EVM forms: + +| Input | Recognized as | Validated by | +|---|---|---| +| `T…` (base58, 26–41 chars) | `tron-base58` | Base58Check checksum | +| `41…` / `0x41…` (21 bytes hex) | `tron-hex` | length + prefix | +| `0x…` (20 bytes hex) | `evm` | EIP-55 checksum, when the input is mixed-case | +| `02…` / `03…` (33 bytes) or `04…` (65 bytes) | `public-key` | secp256k1 point validity | + +**Generic bytes** — prints the hex, Base64, and Base58Check forms: + +| Input | Recognized as | +|---|---| +| `[0-9a-f]+` / `0x…`, even number of digits | `hex` | +| Canonical Base58Check | `base58check` | +| Canonical Base64 | `base64` | + +Checksums are enforced, not merely displayed. A base58 address with a typo fails +(`base58 checksum mismatch (typo in the address?)`), and a mixed-case EVM address whose EIP-55 +capitalization does not match is rejected — an all-lowercase or all-uppercase EVM address carries no +checksum, so it is accepted and re-emitted in checksummed form. + +### Private-key safety + +A generic input that decodes to **exactly 32 bytes** is refused: + +```console +error [invalid_value]: 32-byte input may be a private key and is not accepted on argv +``` + +32 bytes is the size of a secp256k1 private key, and argv is visible in the process list and shell +history. The command cannot tell a private key from any other 32-byte blob, so it declines the +whole class rather than risk leaking one. Decoded input is otherwise limited to 1 byte – 1 MiB. + +## Arguments + +- `input` — the value to convert (positional, or `--input `) + +> **Pass `0x…` values with `--input`.** A `0x`-prefixed value given *positionally* is interpreted as +> a number by the argv parser and rejected before it reaches the converter: +> +> ```console +> error [invalid_value]: invalid --input: Invalid input: expected string, received number +> ``` +> +> `wallet-cli encoding convert --input 0x…` is unaffected, as is the un-prefixed `41…` hex form. + +## Options + +| Option | Description | +|---|---| +| `--input ` | The value to convert; equivalent to the positional, and required for `0x…` inputs | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +A TRON address, in every form: + +```bash +wallet-cli encoding convert TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +``` + +```console +TRON TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +TRON hex 41a614f803b6fd780986a42c78ec9c7f77e6ded13c +EVM 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C +``` + +The conversion is symmetric — feeding back the EVM form yields the same three rows: + +```bash +wallet-cli encoding convert --input 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C +``` + +```console +TRON TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +TRON hex 41a614f803b6fd780986a42c78ec9c7f77e6ded13c +EVM 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C +``` + +Generic bytes: + +```bash +wallet-cli encoding convert deadbeef0102 +``` + +```console +Hex deadbeef0102 +Base64 3q2+7wEC +Base58Check DWcJPafcQr2coF +``` + +```bash +wallet-cli encoding convert TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"encoding.convert","data":{"input":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","inputType":"tron-base58","valid":true,"tron":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","tronHex":"41a614f803b6fd780986a42c78ec9c7f77e6ded13c","evm":"0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C"},"meta":{"durationMs":47,"warnings":[]}} +``` + +## Output + +`data` is a local result — no `chain` block. The shape depends on which family was detected; +`inputType` tells you which, so a consumer can branch on it. + +Address / public-key inputs: + +| Field | Type | Meaning | +|---|---|---| +| `input` | string | The value as passed | +| `inputType` | string | `tron-base58` \| `tron-hex` \| `evm` \| `public-key` | +| `valid` | boolean | Always `true` — invalid input is an error, not a `false` result | +| `tron` | string | TRON base58 address | +| `tronHex` | string | `41`-prefixed 21-byte hex address | +| `evm` | string | EIP-55 checksummed EVM address | + +Generic inputs: + +| Field | Type | Meaning | +|---|---|---| +| `input` | string | The value as passed | +| `inputType` | string | `hex` \| `base64` \| `base58check` | +| `valid` | boolean | Always `true` | +| `hex` | string | Lowercase hex, no `0x` prefix | +| `base64` | string | Canonical Base64 | +| `base58check` | string | Base58Check with a sha256 checksum | + +## Exit status + +`0` · `2` usage error — `invalid_value` for a checksum mismatch, an odd-length hex string, an +unrecognized encoding, a 32-byte input, empty input, or whitespace/control characters. + +## See also + +[`address generate`](../address/generate.md) · [`contract info`](../contract/info.md) · +[`account info`](../account/info.md) diff --git a/ts/docs/commands/encoding/index.md b/ts/docs/commands/encoding/index.md new file mode 100644 index 000000000..66da50bca --- /dev/null +++ b/ts/docs/commands/encoding/index.md @@ -0,0 +1,30 @@ +# wallet-cli encoding + +Local encoding conversion and validation. + +## Synopsis + +``` +wallet-cli encoding COMMAND +``` + +## Subcommands + +| Command | Description | Network | +|---|---|---| +| [`encoding convert`](convert.md) | Convert and validate address, hex, Base64, and Base58Check encodings | none | + +## Why this exists + +The same TRON identity has several textual spellings — base58 `T…`, the `41`-prefixed hex the +protocol actually uses, and the `0x…` EVM form — and TRON tooling mixes all three. Explorers, +contract ABIs, and node RPC responses each favour a different one, and a value copied from one +into another is a common, silent source of failure. + +`encoding convert` resolves that locally: give it any of the forms and it prints all the +equivalents, verifying the checksum on the way. + +## See also + +[`address generate`](../address/generate.md) · [`contract call`](../contract/call.md) · +[Accounts & HD](../../concepts/accounts-and-hd.md) diff --git a/ts/docs/commands/gasfree/index.md b/ts/docs/commands/gasfree/index.md new file mode 100644 index 000000000..d9b58e1c4 --- /dev/null +++ b/ts/docs/commands/gasfree/index.md @@ -0,0 +1,70 @@ +# wallet-cli gasfree + +Transfer tokens without holding any TRX, via the GasFree Open Platform. + +## Synopsis + +``` +wallet-cli gasfree COMMAND +``` + +## Subcommands + +| Command | Description | Broadcasts | +|---|---|---| +| [`gasfree info`](info.md) | Show GasFree address, activation status, nonce, balances, and fees | no | +| [`gasfree transfer`](transfer.md) | Sign and submit a TIP-712 GasFree token transfer | ✍️ yes | +| [`gasfree trace`](trace.md) | Track a GasFree transfer by provider trace id | no | + +## What GasFree is + +Normally a TRC20 transfer costs TRX — you need bandwidth and energy, so an account holding only +USDT cannot move it. GasFree removes that requirement: instead of broadcasting a transaction, you +**sign a TIP-712 `PermitTransfer` authorization** and hand it to a service provider, who broadcasts +on your behalf and takes their fee **in the token being transferred**. + +So the flow is not "send a transaction" but "sign an intent, then track it": + +``` +gasfree transfer ──sign PermitTransfer──> provider accepts (traceId) + │ + WAITING → INPROGRESS → CONFIRMING → SUCCEED | FAILED + │ + gasfree trace +``` + +Your **GasFree address** is a distinct, deterministic address derived from your account — it is +*not* your normal TRON address. Tokens must be in the GasFree address to be transferable this way; +see [`gasfree info`](info.md). + +## Configuration + +GasFree requires Open Platform credentials, stored via [`config`](../config.md): + +```bash +wallet-cli config gasfreeApiKey +wallet-cli config gasfreeApiSecret +``` + +The secret is never echoed back — reading it returns `********`. + +Endpoint and TIP-712 domain come from the network descriptor, not from your config: + +| Network | GasFree endpoint | Supported | +|---|---|---| +| `tron:mainnet` | `https://open.gasfree.io` | yes | +| `tron:nile` | `https://open-test.gasfree.io` | yes | +| `tron:shasta` | — | **no** — `unsupported_network` | + +## Integrity checks + +Provider responses are untrusted input, and the CLI validates rather than displays them. It fails +with `gasfree_integrity` if the provider reports an owner that is not the selected account, if +token and address fee metadata disagree, if fee arithmetic does not add up, or if a trace names a +token outside the current configuration. Signing likewise fails (`signing_rejected`) unless the +signed digest is exactly the expected `PermitTransfer` and recovers to the selected account. + +## See also + +[`tx send`](../tx/send.md) — the ordinary, TRX-paying transfer · +[`token balance`](../token/balance.md) · [`config`](../config.md) diff --git a/ts/docs/commands/gasfree/info.md b/ts/docs/commands/gasfree/info.md new file mode 100644 index 000000000..b370295bc --- /dev/null +++ b/ts/docs/commands/gasfree/info.md @@ -0,0 +1,77 @@ +# wallet-cli gasfree info + +Show GasFree address, activation status, nonce, balances, and fees. + +## Synopsis + +``` +wallet-cli gasfree info [options] +``` + +## Description + +Reports everything you need before a [`gasfree transfer`](transfer.md), for the active account (or +`--account`): + +- the **GasFree address** derived from your account — a different address from your ordinary TRON + one, and the address that must actually hold the tokens +- whether it is **active**; an inactive address pays a one-time activation fee on its first transfer +- the current **nonce**, which the signed authorization is bound to +- per supported token: the balance held at the GasFree address, and the current activation and + transfer fees **denominated in that token** + +Read-only: no signing, no unlock, and nothing is submitted. Requires GasFree credentials in +[`config`](../config.md). + +Fees are quoted by the provider and change over time — read them here rather than assuming a +constant. The CLI cross-checks the fee metadata from the token list against the address response +and fails with `gasfree_integrity` if they disagree. + +## Options + +Only the [global options](../index.md#global-options-every-command) (`--account`, `--network`, …). + +## Examples + +```bash +wallet-cli gasfree info --network tron:nile +``` + +```console +Owner TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ +GasFree address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 +Status active +Nonce 3 + +| Token | Balance | Activation fee | Transfer fee | +| ----- | ---------- | -------------- | ------------ | +| USDT | 125.5 USDT | 1 USDT | 1 USDT | +``` + +`Status not activated` means the first transfer will additionally deduct the activation fee. + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `ownerAddress` | string | The selected account's TRON address | +| `gasFreeAddress` | string | Derived GasFree address that holds the tokens | +| `active` | boolean | Whether the GasFree address is activated | +| `nonce` | string | Current authorization nonce | +| `tokens[].symbol` | string | Token symbol | +| `tokens[].address` | string | Token contract address | +| `tokens[].decimals` | number | Token decimals | +| `tokens[].balance` | string | Raw balance at the GasFree address, in base units | +| `tokens[].activateFee` | string | One-time activation fee, in token base units | +| `tokens[].transferFee` | string | Per-transfer service fee, in token base units | + +Amounts are raw base-unit strings in JSON; the text renderer applies `decimals`. + +## Exit status + +`0` · `1` execution failure (`gasfree_integrity`, provider unreachable, missing/invalid +credentials) · `2` usage error (`unsupported_network` on `tron:shasta`). + +## See also + +[`gasfree transfer`](transfer.md) · [`gasfree trace`](trace.md) · [`config`](../config.md) diff --git a/ts/docs/commands/gasfree/trace.md b/ts/docs/commands/gasfree/trace.md new file mode 100644 index 000000000..ab033e8e9 --- /dev/null +++ b/ts/docs/commands/gasfree/trace.md @@ -0,0 +1,94 @@ +# wallet-cli gasfree trace + +Track a GasFree transfer by provider trace id. + +## Synopsis + +``` +wallet-cli gasfree trace [options] +``` + +## Description + +A GasFree transfer is not on-chain when it is accepted — the provider queues it, then broadcasts. +[`gasfree transfer`](transfer.md) returns a `traceId`; this command turns that id into the current +state. + +| State | Meaning | +|---|---| +| `WAITING` | Accepted, not yet picked up | +| `INPROGRESS` | Being processed by the provider | +| `CONFIRMING` | Broadcast, awaiting chain confirmation | +| `SUCCEED` | Terminal — on chain, `txId` is populated | +| `FAILED` | Terminal — `failureReason` explains why | + +Once the transfer settles, the reported amount and fees switch from the estimates to the **actual** +settled values, so a completed trace is the authoritative record of what was deducted. + +No wallet unlock and no signing; it only needs GasFree credentials and the trace id. Because it is +wallet-independent, you can track a transfer from a machine that holds no keys. + +## Arguments + +- `traceId` — the provider trace id returned by [`gasfree transfer`](transfer.md) (positional) + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 --network tron:nile +``` + +```console +Trace ID 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 +Status succeed +TxID 5b1c0d9e7a4f2c8b6d3e1a0f9c7b5d3a1e8f6c4b2d0a9e7c5b3d1f8a6c4e2b0d +Token USDT +Amount 25 USDT +Service fee 1 USDT +Activation fee 0 USDT +Total 26 USDT +To TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +``` + +Poll until terminal in a script: + +```bash +until wallet-cli gasfree trace "$TRACE" -o json \ + | jq -e '.data.state == "SUCCEED" or .data.state == "FAILED"' >/dev/null; do + sleep 5 +done +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `traceId` | string | Provider trace id | +| `state` | string | `WAITING` \| `INPROGRESS` \| `CONFIRMING` \| `SUCCEED` \| `FAILED` | +| `txId` | string | On-chain transaction hash — present once broadcast | +| `token` | string | Token symbol | +| `tokenAddress` | string | Token contract address | +| `decimals` | number | Token decimals | +| `amount` | string | Settled amount if available, else the submitted amount (base units) | +| `serviceFee` | string | Settled or estimated transfer fee (base units) | +| `activateFee` | string | Settled or estimated activation fee (base units) | +| `totalDeducted` | string | Total taken from the GasFree address (base units) | +| `from` | string | GasFree address the tokens left | +| `owner` | string | Account that authorized the transfer | +| `to` | string | Recipient | +| `nonce` | string | Authorization nonce | +| `failureReason` | string | Present only when `state` is `FAILED` | + +## Exit status + +`0` — including for a `FAILED` transfer: the *query* succeeded, so check `data.state` rather than +the exit code · `1` execution failure (`gasfree_integrity`, provider unreachable, unknown trace +id) · `2` usage error (malformed trace id, `unsupported_network`). + +## See also + +[`gasfree transfer`](transfer.md) · [`gasfree info`](info.md) · [`tx status`](../tx/status.md) diff --git a/ts/docs/commands/gasfree/transfer.md b/ts/docs/commands/gasfree/transfer.md new file mode 100644 index 000000000..caf8b4ef3 --- /dev/null +++ b/ts/docs/commands/gasfree/transfer.md @@ -0,0 +1,154 @@ +# wallet-cli gasfree transfer + +Sign and submit a TIP-712 GasFree token transfer. ✍️ + +## Synopsis + +``` +wallet-cli gasfree transfer --to --amount + [--token ] [--dry-run] [options] +``` + +## Description + +Moves tokens **without spending any TRX**. Instead of building and broadcasting a transaction, the +command signs a TIP-712 `PermitTransfer` authorization and submits it to the GasFree provider, who +broadcasts it and takes the fee in the transferred token. + +Two consequences worth internalizing: + +1. The tokens must sit in your **GasFree address**, not your ordinary TRON address — check with + [`gasfree info`](info.md). +2. Success here means **accepted by the provider**, not confirmed on chain. The command returns a + `traceId`; follow it with [`gasfree trace`](trace.md) or use `--wait`. + +`--to` accepts a TRON address or a local [contact](../contact/index.md) name. When a contact is +used, the receipt shows both — `To alice (TR7NHq…)` — so the resolution stays auditable. + +`--token` defaults to `USDT` and must be one the provider supports. + +### What is deducted + +| Component | When | +|---|---| +| the transfer `amount` | always | +| the **transfer fee** | always | +| the **activation fee** | only if your GasFree address is not yet active | + +The command checks the GasFree balance covers `amount + activation + transfer fee` up front and +fails with `insufficient_token_balance` (with `balance` and `required` in the error details) rather +than letting the provider reject it later. + +Note that the **authorized max fee** in the signature is always `activationFee + transferFee`, even +for an already-active address — it is an upper bound the provider may not exceed, not the amount +charged. Both appear in the output so the difference is visible. + +The signed authorization is bound to the current nonce and to a provider-supplied deadline, so it +cannot be replayed. + +### `--dry-run` + +Resolves the recipient, selects the token and provider, computes the exact fee breakdown, and +checks the balance — then stops. Nothing is signed, no unlock is needed, and nothing is submitted. +`--dry-run` cannot be combined with `--wait`. + +### `--wait` + +Polls the trace until a terminal state, then reports the **settled** amount and fees rather than +the estimates, with `stage` of `confirmed` (`SUCCEED`) or `failed` (`FAILED`). If the timeout +elapses first, it warns and returns the submitted receipt — the transfer is still in flight, so +track it with [`gasfree trace`](trace.md). + +## Options + +| Option | Description | +|---|---| +| `--to ` | **Required.** Recipient TRON address or local contact name | +| `--amount ` | **Required.** Human token amount; must be greater than zero | +| `--token ` | Token symbol (default `USDT`) | +| `--dry-run` | Check balance and fee breakdown without unlocking, signing, or submitting | +| `--wait` / `--wait-timeout ` | Poll the trace until it reaches a terminal state | +| `--password-stdin` | Master password from stdin (software accounts) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Price it first — no unlock, nothing submitted: + +```bash +wallet-cli gasfree transfer --to TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t \ + --amount 25 --network tron:nile --dry-run +``` + +```console +⏳ Dry run — GasFree transfer 25 USDT (not submitted) + From TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + To TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + Service fee 1 USDT + Activation fee 0 USDT + Authorized max fee 2 USDT + Total 26 USDT + Status not submitted +``` + +Submit it, to a contact by name: + +```bash +echo "$PW" | wallet-cli gasfree transfer --to alice --amount 25 \ + --network tron:nile --password-stdin +``` + +```console +⏳ Submitted to GasFree — send 25 USDT + Trace ID 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 + From TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + To alice (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t) + Service fee 1 USDT + Activation fee 0 USDT + Total 26 USDT + Status waiting +! Track it: wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 +``` + +Submit and wait for the terminal state: + +```bash +echo "$PW" | wallet-cli gasfree transfer --to alice --amount 25 \ + --network tron:nile --wait --password-stdin +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"gasfree-transfer"` | +| `stage` | string | `"dry-run"` \| `"submitted"` \| `"confirmed"` \| `"failed"` | +| `traceId` | string | Provider trace id — absent for `--dry-run` | +| `state` | string | Provider state (`WAITING` … `SUCCEED` / `FAILED`) | +| `txId` | string | On-chain hash, once the provider has broadcast | +| `token`, `tokenAddress`, `decimals` | — | Token identity | +| `amount` | string | Transfer amount in base units | +| `serviceFee` | string | Transfer fee in base units | +| `activateFee` | string | Activation fee in base units — `0` when already active | +| `authorizedMaxFee` | string | Fee ceiling covered by the signature | +| `totalDeducted` | string | `amount + activateFee + serviceFee` | +| `owner` | string | Account that authorized the transfer | +| `from` | string | GasFree address the tokens leave | +| `to` | string | Resolved recipient address | +| `toContact` | string | Contact name, when `--to` was a contact | +| `serviceProvider` | string | Provider address | +| `nonce`, `deadline` | string | Authorization binding | +| `failureReason` | string | Present when `stage` is `failed` | + +## Exit status + +`0` submitted (or dry-run) · `1` execution failure — `insufficient_token_balance`, +`gasfree_rejected` (address not permitted to submit), `gasfree_integrity`, `signing_rejected`, +`auth_failed` · `2` usage error — `invalid_option` (`--dry-run` with `--wait`), `invalid_amount`, +`unsupported_network` (`tron:shasta`), unknown token or contact. + +## See also + +[`gasfree info`](info.md) · [`gasfree trace`](trace.md) · [`tx send`](../tx/send.md) · +[`contact add`](../contact/add.md) diff --git a/ts/docs/commands/index.md b/ts/docs/commands/index.md index 130df9032..37a895295 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -19,6 +19,8 @@ Every command — including every subcommand — has its own page, following a f | `backup` | [backup.md](backup.md) | | `delete` | [delete.md](delete.md) | | `change-password` | [change-password.md](change-password.md) | +| `address` (group) | [address/index.md](address/index.md) | +| `address generate` | [address/generate.md](address/generate.md) | ## Transactions @@ -28,9 +30,28 @@ Every command — including every subcommand — has its own page, following a f | `tx send` | [tx/send.md](tx/send.md) | | `tx sign` | [tx/sign.md](tx/sign.md) | | `tx broadcast` | [tx/broadcast.md](tx/broadcast.md) | +| `tx approvals` | [tx/approvals.md](tx/approvals.md) | +| `tx multisig` | [tx/multisig.md](tx/multisig.md) | | `tx status` | [tx/status.md](tx/status.md) | | `tx info` | [tx/info.md](tx/info.md) | +## Multi-signature permissions + +| Command | Page | +|---|---| +| `permission` (group) | [permission/index.md](permission/index.md) | +| `permission show` | [permission/show.md](permission/show.md) | +| `permission update` | [permission/update.md](permission/update.md) *(can lock you out — read first)* | + +## Gas-free transfers + +| Command | Page | +|---|---| +| `gasfree` (group) | [gasfree/index.md](gasfree/index.md) | +| `gasfree info` | [gasfree/info.md](gasfree/info.md) | +| `gasfree transfer` | [gasfree/transfer.md](gasfree/transfer.md) | +| `gasfree trace` | [gasfree/trace.md](gasfree/trace.md) | + ## On-chain queries | Command | Page | @@ -40,6 +61,8 @@ Every command — including every subcommand — has its own page, following a f | `account info` | [account/info.md](account/info.md) | | `account history` | [account/history.md](account/history.md) | | `account portfolio` | [account/portfolio.md](account/portfolio.md) | +| `account activate` | [account/activate.md](account/activate.md) | +| `account set` | [account/set.md](account/set.md) | | `block` | [block.md](block.md) | | `chain` (group) | [chain/index.md](chain/index.md) | | `chain params` | [chain/params.md](chain/params.md) | @@ -98,6 +121,12 @@ Every command — including every subcommand — has its own page, following a f |---|---| | `config` | [config.md](config.md) | | `networks` | [networks.md](networks.md) | +| `contact` (group) | [contact/index.md](contact/index.md) | +| `contact add` | [contact/add.md](contact/add.md) | +| `contact list` | [contact/list.md](contact/list.md) | +| `contact remove` | [contact/remove.md](contact/remove.md) | +| `encoding` (group) | [encoding/index.md](encoding/index.md) | +| `encoding convert` | [encoding/convert.md](encoding/convert.md) | ## Global options (every command) @@ -110,4 +139,15 @@ Every command — including every subcommand — has its own page, following a f -h, --help / -V, --version ``` -Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000) and `--dry-run` / `--sign-only`. +Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000) and the mutually exclusive execution-mode flags: + +``` +--dry-run build and estimate only — no signature, no broadcast +--sign-only sign and output the complete transaction hex +--build-only build unsigned transaction hex without unlocking +--permission-id <0-9> TRON permission group authorizing this transaction (default 0) +--expiration expiration duration, 1–86400000; only with --sign-only / --build-only +``` + +Not every broadcast command exposes all of these — `gasfree transfer` signs a TIP-712 authorization +rather than building a TRON transaction, so it takes only `--dry-run`. Check the command's page. diff --git a/ts/docs/commands/permission/index.md b/ts/docs/commands/permission/index.md new file mode 100644 index 000000000..7ebf3ab86 --- /dev/null +++ b/ts/docs/commands/permission/index.md @@ -0,0 +1,65 @@ +# wallet-cli permission + +Inspect and replace a TRON account's multi-signature permission structure. + +## Synopsis + +``` +wallet-cli permission COMMAND +``` + +## Subcommands + +| Command | Description | Broadcasts | +|---|---|---| +| [`permission show`](show.md) | Show owner, witness, and active permission groups | no | +| [`permission update`](update.md) | Replace the complete account permission structure | ✍️ yes | + +## The TRON permission model + +Every TRON account has a permission structure that decides **which keys may authorize what**. It +has three kinds of group: + +| Group | id | Purpose | +|---|---|---| +| **owner** | `0` | Full control, including replacing the permission structure itself. Exactly one. | +| **witness** | `1` | Block production. Only meaningful for super representatives; at most one, with exactly one key. | +| **active** | `2`–`9` | Scoped day-to-day authority — each active group carries an explicit list of allowed operations. Up to 8. | + +Each group has: + +- **keys** — 1 to 5 addresses, each with a positive **weight** +- a **threshold** — the total weight a transaction must accumulate to be authorized; it may not + exceed the sum of the group's key weights + +A single-key account is just the degenerate case: one key of weight 1 and a threshold of 1. Once a +threshold exceeds any one key's weight, transactions need co-signing — that is what the +[`tx sign`](../tx/sign.md) / [`tx approvals`](../tx/approvals.md) / +[`tx multisig`](../tx/multisig.md) workflow exists for. + +Active groups additionally carry an **operations bitmap** — 32 bytes, one bit per TRON contract +type — which is what limits an active permission to, say, transfers and staking but not permission +changes. + +## Choosing the permission for a transaction + +Signing commands take `--permission-id <0-9>` to say *which group authorizes this transaction* +(default `0`, the owner). The id must be a group that actually permits the operation, and the +signer must be one of its keys. + +## The lockout risk + +`permission update` is a **complete replacement**, not a patch, and the account's own owner +permission is what governs future changes. A structure whose owner group contains no key this +wallet holds — or whose threshold your local keys cannot reach — leaves the account +permanently unusable from here. There is no recovery path and no support channel that can undo it. + +The CLI emits warnings for the recognizable cases (`owner_lockout`, `owner_lockout_partial`, +`active_can_update_permission`, `active_unknown_operations`), but they are warnings, not blocks. +Always run [`permission update --dry-run`](update.md) first and read the rendered structure back +before broadcasting. + +## See also + +[`tx sign`](../tx/sign.md) · [`tx approvals`](../tx/approvals.md) · +[`tx multisig`](../tx/multisig.md) · [Security model](../../concepts/security.md) diff --git a/ts/docs/commands/permission/show.md b/ts/docs/commands/permission/show.md new file mode 100644 index 000000000..6875bf8e0 --- /dev/null +++ b/ts/docs/commands/permission/show.md @@ -0,0 +1,103 @@ +# wallet-cli permission show + +Show owner, witness, and active permission groups. + +## Synopsis + +``` +wallet-cli permission show [--account ] [options] +``` + +## Description + +Reads the account's permission structure from the node and renders it with the operation bitmaps +**decoded** — so an active group shows `Transfer TRX · Vote · TRX Stake (2.0)` rather than 32 bytes +of hex. Read-only; no unlock, no broadcast. + +`--account` accepts a local account (id or label) **or any activated TRON address**, so you can +inspect an account this wallet does not hold keys for. + +Keys that belong to this wallet are annotated with the owning account label: + +``` + TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ 1 (this wallet: main) +``` + +That annotation is what tells you whether you can still authorize owner-level operations — read it +before running [`permission update`](update.md). + +Both forms are shown for active groups: the decoded operation labels and the raw `operationsHex`. +Bits the build has no name for are listed explicitly as `Unknown contract type ` rather than +being dropped, so no granted scope is invisible. + +## Options + +Only the [global options](../index.md#global-options-every-command) (`--account`, `--network`, …). + +## Examples + +```bash +wallet-cli permission show --network tron:nile +``` + +```console +Account main (TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ) + +Permission Name owner (id 0) +Threshold 2 +Authorized To Address Weight + TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ 1 (this wallet: main) + TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 1 + +Permission Name operations (id 2, active) +Operation(s) Transfer TRX · Transfer TRC10 · Vote · TRX Stake (2.0) (4 total) +Operations Hex 1600000000004000000000000000000000000000000000000000000000000000 +Threshold 1 +Authorized To Address Weight + TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 1 +``` + +Inspect any account by address: + +```bash +wallet-cli permission show --account TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t \ + --network tron:nile -o json +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `address` | string | Account whose permissions these are | +| `owner` | object | The owner group (id `0`) | +| `witness` | object \| null | The witness group (id `1`), or `null` | +| `actives` | array | Active groups (ids `2`–`9`) | + +Every group carries: + +| Field | Type | Meaning | +|---|---|---| +| `id` | number | Permission group id | +| `name` | string | Group name | +| `threshold` | number | Weight required to authorize | +| `keys[].address` | string | Authorized key | +| `keys[].weight` | number | That key's weight | +| `keys[].local` | string \| null | Local account label if this wallet holds the key, else `null` | + +Active groups additionally carry: + +| Field | Type | Meaning | +|---|---|---| +| `operations` | string[] | Allowed contract types, e.g. `["TransferContract","VoteWitnessContract"]` | +| `operationLabels` | string[] | Human labels for the same, e.g. `["Transfer TRX","Vote"]` | +| `operationsHex` | string | The raw 32-byte bitmap | +| `unknownOperationIds` | number[] | Set bits this build has no name for | + +## Exit status + +`0` · `1` execution failure (node unreachable, account not activated) · `2` usage error. + +## See also + +[`permission update`](update.md) · [`account info`](../account/info.md) · +[`tx approvals`](../tx/approvals.md) diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md new file mode 100644 index 000000000..d68279e87 --- /dev/null +++ b/ts/docs/commands/permission/update.md @@ -0,0 +1,195 @@ +# wallet-cli permission update + +Replace the complete account permission structure. ✍️ + +> **This can permanently lock you out of the account.** It is a full replacement, and the owner +> permission is what authorizes all future changes. Run `--dry-run` first and read the rendered +> structure back before broadcasting. See [the lockout risk](index.md#the-lockout-risk). + +## Synopsis + +``` +wallet-cli permission update (--file | --json ) + [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +Submits one `AccountPermissionUpdateContract` that replaces the account's **owner, witness, and +active** permissions in a single atomic change. There is no partial update: whatever you pass is +the account's entire permission structure afterwards, and anything you omit is gone. + +The structure is validated strictly before anything is built, and rejected with `invalid_permission` +(exit 2) on any violation — never silently normalized. + +Exactly one of `--file` / `--json`. Files are read with a 1 MiB cap. Numbers are parsed +losslessly, so a threshold or weight beyond the safe-integer range is rejected rather than rounded. + +### The permission JSON + +```json +{ + "owner": { + "id": 0, + "name": "owner", + "threshold": 2, + "keys": [ + { "address": "TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ", "weight": 1 }, + { "address": "TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2", "weight": 1 } + ] + }, + "actives": [ + { + "id": 2, + "name": "operations", + "threshold": 1, + "operations": [ + "TransferContract", + "TransferAssetContract", + "VoteWitnessContract", + "FreezeBalanceV2Contract" + ], + "keys": [ + { "address": "TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2", "weight": 1 } + ] + } + ] +} +``` + +| Field | Required | Rules | +|---|---|---| +| `address` | no | If present, must equal the selected account's address | +| `owner` | **yes** | `id` must be `0`; must **not** define `operations` | +| `witness` | no | `id` must be `1`; exactly one key; must **not** define `operations`; omit or `null` for non-SR accounts | +| `actives` | no | At most 8 groups; ids `2`–`9` and unique; defaults to `[]` | + +Rules that apply to every group: + +- `keys` — 1 to 5 entries, valid TRON addresses, **no duplicates** +- `weight` — a positive safe integer +- `threshold` — a positive safe integer that **may not exceed the sum of the group's key weights** + (an unreachable threshold is a lockout, so it is refused) +- `name` — at most 32 UTF-8 bytes, no control characters; defaults to the group kind + +Active groups must grant at least one operation. Contract-type names are the TRON protocol names +(`TransferContract`, `TriggerSmartContract`, `AccountPermissionUpdateContract`, …); an unrecognized +one is refused rather than ignored. Use [`permission show`](show.md) on an existing account to see +the exact spelling. + +### `operations` and `operationsHex` + +Normally you list `operations` and the bitmap is computed for you. You may also supply +`operationsHex` — but then it must **agree** with `operations`, and any set bit that has no known +contract type must be declared verbatim in `unknownOperationIds`: + +```json +{ "id": 2, "name": "ops", "threshold": 1, + "operations": ["TransferContract"], + "operationsHex": "02000000000000000000000000000000000000000000000000000000000000c0", + "unknownOperationIds": [254, 255], + "keys": [{ "address": "T…", "weight": 1 }] } +``` + +This exists so a bitmap cannot quietly widen a permission while the human-readable `operations` +list stays unchanged. A mismatch is `invalid_permission`. + +### Safety warnings + +Warnings are emitted into `meta.warnings` before the transaction is built. They do **not** block +the update: + +| Code | Meaning | +|---|---| +| `owner_lockout` | Local keys hold **no** owner weight — applying this may permanently lock out this wallet | +| `owner_lockout_partial` | Local keys hold less than the owner threshold — co-signers will be required | +| `active_can_update_permission` | An active group can itself replace the permission structure | +| `active_unknown_operations` | An active group grants contract types this build cannot name | +| `permission_postcheck_mismatch` | After confirmation, the on-chain structure differs from what was requested | + +The command also refuses to broadcast when the account balance is below the network's permission +update fee (`insufficient_balance`) — this fee is substantial on mainnet, so check it with +`--dry-run`. + +## Options + +| Option | Description | +|---|---| +| `--file ` | Complete replacement permission JSON file (≤ 1 MiB) | +| `--json ` | The same JSON inline | +| `--dry-run` | Validate, build, and estimate without signing or broadcasting | +| `--sign-only` | Build and sign, then output the complete transaction hex | +| `--build-only` | Build and output unsigned transaction hex without unlocking | +| `--permission-id <0-9>` | Permission group authorizing *this* transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--password-stdin` | Master password from stdin (software accounts) | + +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Always start here — validate and price the change without signing: + +```bash +wallet-cli permission update --file permissions.json --network tron:nile --dry-run +``` + +```console +⏳ Permission update dry run + Fee 100 TRX + Status not submitted + +Account TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + +Permission Name owner (id 0) +Threshold 2 +Authorized To Address Weight + TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ 1 (this wallet: main) + TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 1 +``` + +Apply it once the rendered structure is what you intended: + +```bash +echo "$PW" | wallet-cli permission update --file permissions.json \ + --network tron:nile --wait --password-stdin +``` + +Build on an online machine, sign on an offline one: + +```bash +wallet-cli permission update --file permissions.json --network tron:nile --build-only +# → unsigned transaction hex; sign it with `tx sign --hex`, then `tx broadcast --hex` +``` + +A structure whose threshold cannot be met is refused, not submitted: + +```console +error [invalid_permission]: owner.threshold exceeds the total key weight +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"permission-update"` | +| `stage` | string | `"submitted"` / `"confirmed"` / `"failed"` | +| `mode` | string | `"dry-run"` / `"sign-only"` / `"build-only"` when a mode flag was used | +| `txId` | string | Transaction id | +| `hex` | string | Complete transaction hex (`--sign-only` / `--build-only`) | +| `permissions` | object | The canonical structure — as requested for non-broadcast modes, as read back from the chain after confirmation. Same shape as [`permission show`](show.md) | +| `blockNumber`, `feeSun` | — | Present after `--wait` | + +## Exit status + +`0` · `1` execution failure (`insufficient_balance`, `not_authorized`, `auth_failed`, node +rejection) · `2` usage error — `invalid_permission` (any structural violation), neither/both of +`--file` / `--json`, conflicting mode flags. + +## See also + +[`permission show`](show.md) · [`tx sign`](../tx/sign.md) · [`tx approvals`](../tx/approvals.md) · +[Security model](../../concepts/security.md) diff --git a/ts/docs/commands/tx/approvals.md b/ts/docs/commands/tx/approvals.md new file mode 100644 index 000000000..7376b4e9e --- /dev/null +++ b/ts/docs/commands/tx/approvals.md @@ -0,0 +1,115 @@ +# wallet-cli tx approvals + +Show permission, signature approvals, current weight, and expiration. + +## Synopsis + +``` +wallet-cli tx approvals (--hex | --file ) [options] +``` + +## Description + +Answers the question every multi-signature workflow keeps asking: **is this transaction ready to +broadcast yet, and if not, who still has to sign?** + +Given a partially signed transaction artifact, it decodes the transaction, asks the node which +permission group governs it, and reports: + +- what the transaction actually does — contract type, from, to, amount +- which permission group authorizes it, and that group's threshold +- which signers have already approved, and the weight each contributes +- the accumulated weight, the missing weight, and whether the threshold is reached +- when the transaction expires — and whether it already has + +Read-only and wallet-independent: it signs nothing, unlocks nothing, and needs no account. `--hex` +and `--file` are mutually exclusive; exactly one is required. Files are read with a size cap of just +over 1 MiB, and must be regular files (not symlinks). + +It does need a node (`--network`), because approval state — which signatures count, and for how +much weight — is the chain's answer, not something derivable from the artifact alone. + +An **expired** transaction is reported, not rejected: the output flags it and tells you to rebuild, +because collecting further signatures on it would be wasted effort. + +## Options + +| Option | Description | +|---|---| +| `--hex ` | Complete `protocol.Transaction` hex | +| `--file ` | Read the transaction hex from a file | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli tx approvals --file partially-signed.hex --network tron:nile +``` + +```console +Transaction + TxID abc123… + Type Transfer TRX (TransferContract) — 1 TRX + From TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + To TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + Permission active "operations" (id 2) threshold 2 + Expires 2026-07-29 12:34:56 (in 58 minutes) + +Progress 1 / 2 — 1 more weight needed +| Approved signer | Weight | +| ---------------------------------- | ------ | +| TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 | 1 | +``` + +Once enough weight has accumulated, the progress line says so instead: + +```console +Progress 2 / 2 — threshold reached +``` + +Gate a broadcast on it in a script: + +```bash +if wallet-cli tx approvals --file signed.hex --network tron:nile -o json \ + | jq -e '.data.thresholdReached and (.data.expired | not)' >/dev/null; then + wallet-cli tx broadcast --file signed.hex --network tron:nile +fi +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `txId` | string | Transaction id | +| `contractType` | string | TRON contract type, e.g. `TransferContract` | +| `operation` | string | Human label for that contract type, e.g. `Transfer TRX` | +| `from` / `to` | string | Decoded parties, when the contract type exposes them | +| `rawAmount` | string | Amount in base units, when applicable | +| `tokenContract` | string | Token contract, for token transfers | +| `permission.id` | number | Governing permission group id | +| `permission.name` | string | Group name | +| `permission.threshold` | number | Weight required | +| `currentWeight` | number | Weight accumulated so far | +| `missingWeight` | number | Weight still needed — `0` once reached | +| `thresholdReached` | boolean | Whether it can be broadcast | +| `approved[].address` | string | A signer that has approved | +| `approved[].weight` | number | That signer's weight | +| `signatures` | number | Raw signature count on the artifact | +| `expiration` | number | Expiry, epoch ms | +| `expired` | boolean | Whether it is already past | + +`signatures` counts signatures on the artifact; `currentWeight` is what the **node** counts toward +the threshold. They differ when a signature is not from a key in the governing permission — that is +exactly the case worth noticing. + +## Exit status + +`0` — including when the threshold is *not* reached: the query succeeded, so branch on +`data.thresholdReached`, not on the exit code · `1` execution failure (node unreachable, +undecodable transaction) · `2` usage error (neither/both of `--hex` / `--file`). + +## See also + +[`tx sign --check`](sign.md) · [`tx broadcast`](broadcast.md) · +[`tx multisig`](multisig.md) · [`permission show`](../permission/show.md) diff --git a/ts/docs/commands/tx/broadcast.md b/ts/docs/commands/tx/broadcast.md index f4fd1ca6a..144b55022 100644 --- a/ts/docs/commands/tx/broadcast.md +++ b/ts/docs/commands/tx/broadcast.md @@ -1,18 +1,39 @@ # wallet-cli tx broadcast -Broadcast a presigned transaction. +Validate and broadcast a presigned JSON or protobuf-hex transaction. ## Synopsis ``` -wallet-cli tx broadcast (--transaction | --tx-stdin) --network [options] +wallet-cli tx broadcast (--transaction | --tx-stdin | --hex | --file ) + --network [--dry-run] [options] ``` ## Description -Submits a transaction that was signed elsewhere — typically the `data.signed` object from [`tx send --sign-only`](send.md) on an offline or key-holding machine. No wallet unlock is needed; the transaction is already signed. +Submits a transaction that was signed elsewhere — typically the `data.signed` object from [`tx send --sign-only`](send.md) on an offline or key-holding machine, or a co-signed hex artifact from [`tx sign --out`](sign.md). No wallet unlock is needed; the transaction is already signed. -A presigned transaction carries no network of its own, so pass `--network` to say which network to broadcast to (falls back to the config default network when omitted). Exactly one of `--transaction` / `--tx-stdin`. +A presigned transaction carries no network of its own, so pass `--network` to say which network to broadcast to (falls back to the config default network when omitted). Exactly one of `--transaction` / `--tx-stdin` / `--hex` / `--file`. + +### Validation before submission + +Broadcasting is not blind. Whatever form the transaction arrives in, it is decoded and checked +first, and a transaction that cannot succeed is rejected locally instead of being sent: + +- **expired** → `tx_expired` +- **insufficient signature weight** → `not_authorized`, naming the missing weight + +That check is what makes this safe as the last step of a multi-signature workflow: a transaction +that has not yet reached its permission threshold never reaches the node. + +### `--dry-run` + +Runs exactly those checks — signatures, threshold, expiration, and the dynamic multi-sign fee — and +reports the approval state **without broadcasting**. Use it to confirm a collected transaction is +complete before committing it. `--dry-run` cannot be combined with `--wait`. + +A transaction carrying more than one signature also incurs TRON's multi-sign fee; it is reported as +`multiSignFeeSun` in both dry-run and real broadcasts. ## Options @@ -20,6 +41,9 @@ A presigned transaction carries no network of its own, so pass `--network` to sa |---|---| | `--transaction ` | Signed TRON transaction JSON inline | | `--tx-stdin` | Read the signed transaction JSON from stdin (fd 0) | +| `--hex ` | Complete signed `protocol.Transaction` hex | +| `--file ` | Read the signed transaction hex from a file (size-capped, just over 1 MiB) | +| `--dry-run` | Validate signatures, threshold, expiration, and multi-sign fee without broadcasting | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000) | Plus the [global options](../index.md#global-options-every-command). @@ -55,6 +79,19 @@ wallet-cli tx broadcast --tx-stdin --network tron:nile < signed.json {"schema":"wallet-cli.result.v1","success":true,"command":"tx.broadcast","data":{"kind":"broadcast","stage":"submitted","txId":"72a315303323125708f426c77b94c5215afd8964ed27d67e49c29b56e29078f5"},"meta":{"durationMs":926,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` +Broadcast the co-signed artifact at the end of a multi-signature flow, checking it first: + +```bash +wallet-cli tx broadcast --file signed.hex --network tron:nile --dry-run +wallet-cli tx broadcast --file signed.hex --network tron:nile +``` + +An incomplete transaction is refused locally, before the node ever sees it: + +```console +error [not_authorized]: signature threshold is not reached; missing 1 weight +``` + ## Output `data` varies by stage: @@ -63,13 +100,24 @@ wallet-cli tx broadcast --tx-stdin --network tron:nile < signed.json |---|---| | default (submit) | `kind`, `stage: "submitted"`, `txId` | | `--wait` (confirmed/failed) | above, plus `confirmed`, `blockNumber`, `failed`, and result fields | +| `--dry-run` | `kind`, `mode: "dry-run"`, no `txId` — nothing was submitted | + +All stages additionally carry: + +| Field | Type | Meaning | +|---|---|---| +| `transaction` | object | Approval state of the decoded transaction — the same shape [`tx approvals`](approvals.md) returns | +| `multiSignFeeSun` | number | Multi-sign fee in SUN; `0` for a single-signature transaction | As with `tx send`, the default return point is **submission** — confirm via `--wait` or [`tx status`](status.md). ## Exit status -`0` submitted · `1` execution failure (node rejected the tx, timeout) · `2` usage error (both/neither transaction sources). +`0` submitted (or dry-run) · `1` execution failure — `tx_expired`, `not_authorized` (threshold not +reached), node rejection, timeout · `2` usage error — no transaction source, more than one source, +`--dry-run` with `--wait`, malformed JSON. ## See also -[`tx send --sign-only`](send.md) · [`tx status`](status.md) · [Scripting guide](../../guide/scripting.md#sign-here-broadcast-there) +[`tx send --sign-only`](send.md) · [`tx sign`](sign.md) · [`tx approvals`](approvals.md) · +[`tx status`](status.md) · [Scripting guide](../../guide/scripting.md#sign-here-broadcast-there) diff --git a/ts/docs/commands/tx/index.md b/ts/docs/commands/tx/index.md index 969fb1c2f..93652d4df 100644 --- a/ts/docs/commands/tx/index.md +++ b/ts/docs/commands/tx/index.md @@ -13,7 +13,10 @@ wallet-cli tx COMMAND | Command | Description | |---|---| | [`tx send`](send.md) | Send native TRX or TRC20/TRC10 tokens with human `--amount` | -| [`tx broadcast`](broadcast.md) | Broadcast a presigned transaction | +| [`tx sign`](sign.md) | Sign transaction JSON, or append a signature to transaction hex, offline | +| [`tx broadcast`](broadcast.md) | Validate and broadcast a presigned JSON or protobuf-hex transaction | +| [`tx approvals`](approvals.md) | Show permission, signature approvals, current weight, and expiration | +| [`tx multisig`](multisig.md) | Coordinate signature collection through the TronLink service | | [`tx status`](status.md) | Show confirmation status of a transaction | | [`tx info`](info.md) | Show full transaction detail + receipt | @@ -28,6 +31,29 @@ build ──sign──> submit ──solidify──> confirmed `tx send` covers build+sign+submit in one step (with `--dry-run` / `--sign-only` stopping earlier); `tx broadcast` submits what was signed elsewhere; `tx status` / `tx info` observe the outcome. **Submission is not confirmation** — scripts must follow [machine-interface → Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). +## Multi-signature + +When an account's [permission](../permission/index.md) threshold needs more weight than one key +carries, signing stops being a single step and becomes a collection process. Each permitted key +signs the *same* transaction in turn, and it can only be broadcast once the accumulated weight +reaches the threshold: + +``` +build ──> sign ──> sign ──> … ──> threshold reached ──> broadcast + (--build-only) each signer appends; │ + prior signatures preserved └ tx approvals: who signed, how much weight is left +``` + +Two ways to move the transaction between signers: + +| | How it travels | Command | +|---|---|---| +| **Offline / by hand** | A hex artifact you copy from signer to signer | [`tx sign --file … --out`](sign.md) | +| **Via the TronLink service** | A shared queue the service holds | [`tx multisig`](multisig.md) | + +Either way, [`tx approvals`](approvals.md) answers "is it ready yet?", and +[`tx broadcast`](broadcast.md) refuses to submit a transaction that has not reached its threshold. + ## See also [`account history`](../account/index.md) · [Networks & fees](../../concepts/networks.md) · [Scripting guide](../../guide/scripting.md) diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md new file mode 100644 index 000000000..dace0fb8e --- /dev/null +++ b/ts/docs/commands/tx/multisig.md @@ -0,0 +1,178 @@ +# wallet-cli tx multisig + +Coordinate multi-signature collection through the TronLink service. ✍️ + +## Synopsis + +``` +wallet-cli tx multisig # list +wallet-cli tx multisig --create (--hex | --file ) +wallet-cli tx multisig --sign +wallet-cli tx multisig --watch +``` + +## Description + +Collecting signatures is a coordination problem, not a cryptographic one: the transaction has to +reach each co-signer, and someone has to know when enough weight has accumulated. The offline path +([`tx sign --file`](sign.md) → hand the file on → [`tx approvals`](approvals.md)) solves it by +passing an artifact around by hand. + +This command uses the official TronLink multi-sign service as a shared inbox instead: one signer +uploads the unsigned transaction, the others fetch, sign, and submit it, and the service holds the +accumulated signatures in between. TronLink wallet users see the same queue. + +The mode flags `--create`, `--sign`, and `--watch` are mutually exclusive; with none of them the +command lists. + +**The service is a transport, not an authority.** Everything it returns is treated as untrusted: +the CLI re-derives the transaction hash from the raw data, checks the reported contract type, +owner, weights, and signature progress against the transaction itself, and fails with +`provider_error` on any disagreement — rather than showing you what the service claims. Signing +still happens locally with your key. + +### Modes + +**list** (no flag) — service-managed transactions for the selected account, with each one's state, +accumulated weight against the threshold, and whether it is waiting on *you*. + +**`--create`** — upload one **unsigned** transaction and open a collection. Passing an +already-signed transaction is refused (`invalid_value`), as is an expired one (`tx_expired`) or one +whose permission does not include the selected account (`not_authorized`). Requires exactly one of +`--hex` / `--file`, which are valid only with `--create`. + +**`--sign `** — fetch the transaction with the signatures gathered so far, verify it locally, +sign it with your key, and submit the result back. Fails with `already_signed` if this account has +already contributed, `tx_expired` if it has lapsed, or `not_authorized` if the account is not one +of its signers. Once the threshold is reached, the output tells you the broadcast command. + +**`--watch`** — hold a WebSocket open and report when transactions are awaiting your signature. +By design it receives **only a count**, never transaction content, so watching leaks nothing about +what is queued. Runs until interrupted (Ctrl-C, SIGINT/SIGTERM), then reports how many +notifications arrived. + +## Configuration + +Requires TronLink service credentials in [`config`](../config.md): + +```bash +wallet-cli config tronlinkSecretId +wallet-cli config tronlinkSecretKey +wallet-cli config tronlinkChannel +``` + +`tronlinkSecretKey` is masked on read (`********`). The service endpoint comes from the network +descriptor — `api.walletadapter.org` for mainnet, and the Nile and Shasta equivalents. + +## Options + +| Option | Description | +|---|---| +| `--create` | Upload one unsigned transaction and open a signature collection | +| `--hex ` | Unsigned `protocol.Transaction` hex — only with `--create` | +| `--file ` | File containing the unsigned transaction hex — only with `--create` | +| `--sign ` | Fetch and co-sign one pending transaction by 32-byte hex txId | +| `--watch` | Keep a WebSocket open and report the count of transactions awaiting this account | +| `--password-stdin` | Master password from stdin (software accounts) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Open a collection from an unsigned transaction: + +```bash +wallet-cli tx multisig --create --file tx.unsigned.hex --network tron:nile +``` + +```console +✅ Created on TronLink multi-sig service + TxID 9c1f… + +Transaction + TxID 9c1f… + Type Transfer TRX (TransferContract) — 1 TRX + Permission active "operations" (id 2) threshold 2 + Expires 2026-07-29 12:34:56 (in 58 minutes) + +Progress 0 / 2 — 2 more weight needed +No approved signers. +! Each signer signs it with: wallet-cli tx multisig --sign 9c1f… +``` + +See what is waiting on you: + +```bash +wallet-cli tx multisig --network tron:nile +``` + +```console +Multi-sig transactions — TronLink service (1 total) +| TxID | Type | Amount | State | Progress | Expires | +| ----- | ----------------- | ------ | ------------ | -------- | ----------------------------- | +| 9c1f… | TransferContract | 1 TRX | awaiting you | 1 / 2 | 2026-07-29 12:34:56 (in 58m) | +! Co-sign one with: wallet-cli tx multisig --sign +``` + +Co-sign it: + +```bash +echo "$PW" | wallet-cli tx multisig --sign 9c1f… --network tron:nile --password-stdin +``` + +When your signature completes the threshold, the receipt ends with the broadcast command: + +```console +! Broadcast it: wallet-cli tx broadcast --hex 0a02… +``` + +Watch for work, count only: + +```bash +wallet-cli tx multisig --watch --network tron:nile +``` + +```console +Watching TronLink multi-sig service for tron:nile … (Ctrl-C to stop) +🔔 You have 1 transaction(s) to sign — view them with: wallet-cli tx multisig +``` + +## Output + +`data` is discriminated — by `transactions` for the list, and by `action` otherwise. + +**list** + +| Field | Type | Meaning | +|---|---|---| +| `address` | string | Account the queue was read for | +| `total` | number | Number of transactions | +| `transactions[].txId` | string | Transaction id | +| `transactions[].state` | string | `pending` \| `signed` \| `success` \| `failed` | +| `transactions[].contractType` | string | TRON contract type | +| `transactions[].originator` / `owner` | string | Who created it / whose account it acts on | +| `transactions[].permission` | object | `id`, `name`, `threshold` | +| `transactions[].currentWeight` / `missingWeight` / `thresholdReached` | — | Approval progress | +| `transactions[].awaitingMySignature` | boolean | Whether it is waiting on the selected account | +| `transactions[].signedByCurrentAccount` | boolean | Whether this account already signed | +| `transactions[].signatureProgress[]` | array | Per signer: `address`, `weight`, `signed`, `signedAt` | +| `transactions[].createdAt` / `expiration` / `expired` | — | Timing | + +**`--create`** — `action: "create"`, `accepted`, `hex`, and `transaction` (the same approval object +[`tx approvals`](approvals.md) returns). + +**`--sign`** — `action: "sign"`, `accepted`, `signer`, `signerWeight`, `hex`, and `transaction`. + +**`--watch`** — `action: "watch"`, `address`, `notifications`. + +## Exit status + +`0` · `1` execution failure — `not_authorized`, `already_signed`, `tx_expired`, `not_found`, +`provider_error` (any inconsistency in what the service returned), `auth_failed` · `2` usage error — +conflicting mode flags, `--hex`/`--file` without `--create`, `--create` without exactly one of +them, a malformed txId. + +## See also + +[`tx sign`](sign.md) — the offline, file-passing alternative · [`tx approvals`](approvals.md) · +[`tx broadcast`](broadcast.md) · [`permission show`](../permission/show.md) diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index 59a9e466c..0afa7d08d 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -5,7 +5,7 @@ Send native TRX or TRC20/TRC10 tokens with human `--amount`. ## Synopsis ``` -wallet-cli tx send --to
(--amount | --raw-amount ) +wallet-cli tx send --to (--amount | --raw-amount ) [--token | --contract
| --asset-id ] [--dry-run | --sign-only] [--fee-limit ] [options] ``` @@ -19,6 +19,10 @@ Builds, signs, and submits a transfer from the active account (or `--account`). - `--contract
` → TRC20 by contract address; - `--asset-id ` → TRC10 by numeric asset id. +`--to` accepts a TRON address or the name of a local [contact](../contact/index.md). When a contact +name resolves, the receipt shows both — `To alice (TR7NHq…)` — and `data.toContact` carries the +name, so the resolution stays auditable rather than implicit. + Amounts: `--amount` is human units (TRX, or token units respecting the token's decimals); `--raw-amount` is the raw integer (SUN or token base units). Exactly one of the two. Two early exits: `--dry-run` builds and estimates only — no signature, no broadcast, nothing leaves your machine; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](broadcast.md). @@ -31,7 +35,7 @@ Requires an account and the master password via `--password-stdin` — signing c | Option | Description | |---|---| -| `--to ` | **Required.** Recipient TRON base58 address | +| `--to ` | **Required.** Recipient TRON base58 address, or a local [contact](../contact/index.md) name | | `--amount ` | Human amount; mutually exclusive with `--raw-amount` | | `--raw-amount ` | Raw integer amount in SUN / token base units | | `--token ` | Token symbol from the address book; excludes `--contract`, `--asset-id` | @@ -85,7 +89,7 @@ printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH - | Mode | Fields | |---|---| -| default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to` | +| default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to`, plus `toContact` when `--to` was a contact name | | `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `netUsed` (bandwidth used) or `feeSun` (fee burned), `failed` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, e.g. `bandwidthBurnSunIfNoFreeze`), unsigned `tx` (TRON tx object incl. `txID`, `raw_data`), `rawAmount`, `to` | | `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (full signed TRON tx incl. `signature[]` — feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `rawAmount`, `to` | diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index c4b81469b..82bc0cc23 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -5,13 +5,14 @@ Sign a transaction built elsewhere, without broadcasting. ## Synopsis ``` -wallet-cli tx sign --transaction [options] +wallet-cli tx sign (--transaction | --hex | --file ) [options] ``` ## Description Signs a transaction that was constructed outside this CLI with the active account's key (or -`--account`) and prints the signed result. Nothing is broadcast — pass the signed payload to +`--account`) and prints the signed result. JSON input retains the original direct-signing response. +Hex/file input produces a portable protobuf transaction artifact for multi-party signing. Nothing is broadcast — pass the signed payload to [`tx broadcast`](broadcast.md) when you are ready. `--network` is optional: signing itself is offline for software accounts. @@ -42,14 +43,20 @@ Watch-only accounts cannot sign (`watch_only_no_signer`). Ledger accounts sign o ### Multi-sig co-signing -If the transaction you pass already carries a `signature` array, this command **appends** its +With `--hex` or `--file`, the command decodes the complete protobuf transaction and **appends** its signature rather than replacing what is there. That is what TRON multi-sig requires — each permitted key signs the same transaction in turn — so a partially signed transaction can be passed from signer to signer and broadcast once the permission threshold is met. -The consequence worth knowing: `data.signed.signature` may contain signatures this wallet did not -produce. `data.address` always tells you which key *this* invocation used, and the text receipt -numbers them so you can tell them apart: +This path is offline by default: it verifies transaction integrity, expiration, and append-only +signature behavior locally, but does not claim that the signer belongs to the selected permission +or that the threshold has been reached. Use `--check` when connected to a node to verify those +facts through `getsignweight` and `getapprovedlist`, or inspect the result later with +`wallet-cli tx approvals`. + +The JSON compatibility path also preserves existing signatures. `data.signed.signature` may +therefore contain signatures this wallet did not produce. `data.address` always tells you which key +this invocation used, and the text receipt numbers them so you can tell them apart: ```console ✅ Signed transaction @@ -64,6 +71,10 @@ numbers them so you can tell them apart: | Option | Description | |---|---| | `--transaction ` | Unsigned transaction JSON (`raw_data`, `raw_data_hex` and `txID` must agree) | +| `--hex ` | Complete protobuf `protocol.Transaction` hex | +| `--file ` | Read complete transaction hex from a file | +| `--out ` | Atomically write the resulting signed hex to a file | +| `--check` | Verify signer permission and resulting approval weight online | | `--password-stdin` | Master password from stdin (software accounts) | Plus the [global options](../index.md#global-options-every-command). @@ -98,6 +109,21 @@ echo "$PW" | wallet-cli tx sign --transaction "$TX" --password-stdin -o json \ wallet-cli tx broadcast --network tron:nile --tx-stdin < signed.json ``` +Append a signature without an approval RPC dependency, then check it from an online machine: + +```bash +echo "$PW" | wallet-cli tx sign --file partially-signed.hex \ + --out signed.hex --password-stdin +wallet-cli tx approvals --file signed.hex +``` + +To check permission membership and approval progress during signing: + +```bash +echo "$PW" | wallet-cli tx sign --file partially-signed.hex \ + --check --out signed.hex --password-stdin +``` + A transaction whose fields disagree is refused rather than signed: ```console @@ -106,6 +132,8 @@ A transaction whose fields disagree is refused rather than signed: ## Output +`--transaction` retains the original direct-signing shape: + | Field | Type | Meaning | |---|---|---| | `kind` | string | `"sign"` | @@ -116,9 +144,22 @@ A transaction whose fields disagree is refused rather than signed: No `fee` is reported: nothing was estimated, because the transaction was not built here. +`--hex` and `--file` return the artifact-signing shape: + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"tx-sign"` | +| `signer` | string | Address that appended this signature | +| `hex` | string | Complete transaction hex including all signatures | +| `checked` | boolean | Whether online permission/approval verification ran | +| `transaction` | object | Locally decoded transaction summary | +| `signerWeight` | number | Present only when `checked` is true | +| `approval` | object | Present only when `checked` is true; authoritative online approval state | + ## Exit status -`0` signed · `1` execution failure (`tx_integrity`, `watch_only_no_signer`, `auth_failed`, +`0` signed · `1` execution failure (`tx_integrity`, `tx_expired`, `not_authorized`, +`already_signed`, `watch_only_no_signer`, `auth_failed`, `signing_rejected`) · `2` usage error (missing or malformed `--transaction` → `missing_option` / `invalid_value`). diff --git a/ts/docs/guide/ledger.md b/ts/docs/guide/ledger.md index d30f18b5a..3d919eb0e 100644 --- a/ts/docs/guide/ledger.md +++ b/ts/docs/guide/ledger.md @@ -5,7 +5,14 @@ Keep keys on the device; wallet-cli builds transactions and the Ledger signs the ## Prerequisites - Ledger connected, **unlocked**, with the **TRON app open** on the device; -- the TRON app installed via Ledger Live beforehand. +- the TRON app installed via Ledger Live beforehand; +- on Linux, glibc 2.35 or later and the system package providing `libudev.so.1`; +- on Linux, the official [Ledger udev rules](https://github.com/LedgerHQ/udev-rules) installed so a non-root process can open the HID device. +- On Windows, use one of the interactive invocations below. Do not pipe or redirect stdin. + +For Debian and Ubuntu, install the runtime library with `sudo apt-get install libudev1`. After installing or updating udev rules, reload the rules and reconnect the Ledger before retrying. Containers also need the relevant `/dev/hidraw*` device passed through from the host. + +For a reproducible Ubuntu 22.04 image and a device-scoped Docker launch command, see the [Ledger transaction test container](../../docker/ledger/README.md). ## 1. Register the Ledger account @@ -13,6 +20,25 @@ Keep keys on the device; wallet-cli builds transactions and the Ledger signs the wallet-cli import ledger --app tron --index 0 --label cold ``` +To choose an account interactively, omit the locator: + +| Windows shell / host | Command | +|---|---| +| Command Prompt (`cmd.exe`) | `wallet-cli.exe import ledger --app tron` | +| Windows PowerShell 5.1 | `.\wallet-cli.exe import ledger --app tron` | +| PowerShell 7 (`pwsh`) | `.\wallet-cli.exe import ledger --app tron` | +| Windows Terminal | Use the command for its active PowerShell or Command Prompt profile | +| Git Bash / MSYS2 with ConPTY | `./wallet-cli.exe import ledger --app tron` | +| WSL | Run the Linux x64 release as `./wallet-cli import ledger --app tron`; do not use the Windows executable | + +If an older Git Bash/MSYS2 build does not expose raw TTY input, use +`winpty ./wallet-cli.exe import ledger --app tron`. Current Git for Windows releases use the direct +ConPTY form in release testing. + +All Windows shells use `%USERPROFILE%\.wallet-cli` by default, so an account imported in one shell +appears in `list` from the others. If `WALLET_CLI_HOME` is set, every shell must receive the same +absolute path; different values intentionally create isolated wallet stores. + Locally this creates a **watch-only** entry — no secret is stored; signing happens on the device. Three ways to pick the account (mutually exclusive): | Flag | Use when | diff --git a/ts/package-lock.json b/ts/package-lock.json index e88755b24..b6343c2cd 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,16 +1,16 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.10.1", + "version": "4.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tron-walletcli/wallet-cli", - "version": "4.10.1", + "version": "4.11.0", "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", - "@ledgerhq/hw-transport-node-hid": "^6.33.4", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", @@ -20,7 +20,9 @@ "@scure/bip39": "^2.2.0", "axios": "^1.18.1", "lossless-json": "^4.3.0", - "tronweb": "^6.4.0", + "qrcode": "^1.5.4", + "tronweb": "6.4.0", + "ws": "8.21.1", "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.4.3" @@ -30,7 +32,10 @@ }, "devDependencies": { "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", + "bun": "1.3.14", "dependency-cruiser": "^17.4.3", "tsup": "^8.5.1", "tsx": "^4.22.4", @@ -613,22 +618,6 @@ "events": "^3.3.0" } }, - "node_modules/@ledgerhq/hw-transport-node-hid": { - "version": "6.33.4", - "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-6.33.4.tgz", - "integrity": "sha512-/k0rH+wTpTmUifdxWuOER/dM3vPxW7RQIF0tByGC6noszVC3SGOZVcskBqJZ6xwIs1TvAHvZlEFvZWbnUJMBiw==", - "license": "Apache-2.0", - "dependencies": { - "@ledgerhq/devices": "8.15.1", - "@ledgerhq/errors": "^6.36.0", - "@ledgerhq/hw-transport": "6.35.4", - "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", - "@ledgerhq/logs": "^6.17.0", - "lodash": "^4.17.21", - "node-hid": "2.1.2", - "usb": "2.9.0" - } - }, "node_modules/@ledgerhq/hw-transport-node-hid-noevents": { "version": "6.35.4", "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.35.4.tgz", @@ -719,6 +708,230 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz", + "integrity": "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.14.tgz", + "integrity": "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.14.tgz", + "integrity": "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-freebsd-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.3.14.tgz", + "integrity": "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oven/bun-freebsd-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.3.14.tgz", + "integrity": "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.14.tgz", + "integrity": "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-android": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.3.14.tgz", + "integrity": "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.14.tgz", + "integrity": "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.14.tgz", + "integrity": "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-android": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.3.14.tgz", + "integrity": "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.14.tgz", + "integrity": "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.14.tgz", + "integrity": "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.14.tgz", + "integrity": "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.14.tgz", + "integrity": "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.14.tgz", + "integrity": "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.14.tgz", + "integrity": "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1489,11 +1702,25 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/w3c-web-usb": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", - "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", - "license": "MIT" + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/yargs": { "version": "17.0.35", @@ -1715,7 +1942,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1835,6 +2061,47 @@ "ieee754": "^1.1.13" } }, + "node_modules/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "android", + "freebsd", + "win32" + ], + "bin": { + "bun": "bin/bun.exe", + "bunx": "bin/bunx.exe" + }, + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "1.3.14", + "@oven/bun-darwin-x64": "1.3.14", + "@oven/bun-darwin-x64-baseline": "1.3.14", + "@oven/bun-freebsd-aarch64": "1.3.14", + "@oven/bun-freebsd-x64": "1.3.14", + "@oven/bun-linux-aarch64": "1.3.14", + "@oven/bun-linux-aarch64-android": "1.3.14", + "@oven/bun-linux-aarch64-musl": "1.3.14", + "@oven/bun-linux-x64": "1.3.14", + "@oven/bun-linux-x64-android": "1.3.14", + "@oven/bun-linux-x64-baseline": "1.3.14", + "@oven/bun-linux-x64-musl": "1.3.14", + "@oven/bun-linux-x64-musl-baseline": "1.3.14", + "@oven/bun-windows-aarch64": "1.3.14", + "@oven/bun-windows-x64": "1.3.14", + "@oven/bun-windows-x64-baseline": "1.3.14" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1874,6 +2141,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1941,7 +2217,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1954,7 +2229,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2020,6 +2294,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2113,6 +2396,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2472,6 +2761,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -2789,6 +3091,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-installed-globally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", @@ -3155,11 +3466,17 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/lossless-json": { "version": "4.3.0", @@ -3308,17 +3625,6 @@ "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", "license": "MIT" }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-hid": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-2.1.2.tgz", @@ -3370,6 +3676,51 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3426,6 +3777,15 @@ "pathe": "^2.0.1" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -3558,6 +3918,130 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -3636,6 +4120,21 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3798,6 +4297,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4311,27 +4816,6 @@ "dev": true, "license": "MIT" }, - "node_modules/usb": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", - "integrity": "sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@types/w3c-web-usb": "^1.0.6", - "node-addon-api": "^6.0.0", - "node-gyp-build": "^4.5.0" - }, - "engines": { - "node": ">=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0" - } - }, - "node_modules/usb/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4538,6 +5022,12 @@ "node": "^20.12||^22.13||>=24.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4591,9 +5081,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/ts/package.json b/ts/package.json index b0452c38e..b32ba5a48 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.10.1", + "version": "4.11.0", "description": "Agent-first TypeScript CLI wallet for TRON — deterministic commands, JSON output, and discoverable schemas", "type": "module", "bin": { @@ -41,6 +41,7 @@ }, "scripts": { "build": "tsup", + "build:standalone": "bun run scripts/build-standalone.mjs", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", "depcruise": "depcruise src", @@ -51,7 +52,7 @@ "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", - "@ledgerhq/hw-transport-node-hid": "^6.33.4", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", @@ -61,19 +62,24 @@ "@scure/bip39": "^2.2.0", "axios": "^1.18.1", "lossless-json": "^4.3.0", - "tronweb": "^6.4.0", + "qrcode": "^1.5.4", + "tronweb": "6.4.0", + "ws": "8.21.1", "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.4.3" }, "overrides": { "axios": "^1.18.1", - "ws": "^8.18.3", + "ws": "8.21.1", "esbuild": "^0.28.1" }, "devDependencies": { "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", + "bun": "1.3.14", "dependency-cruiser": "^17.4.3", "tsup": "^8.5.1", "tsx": "^4.22.4", diff --git a/ts/scripts/build-standalone.mjs b/ts/scripts/build-standalone.mjs new file mode 100644 index 000000000..afda2673d --- /dev/null +++ b/ts/scripts/build-standalone.mjs @@ -0,0 +1,181 @@ +import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs"; +import { dirname, extname, join, resolve } from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { resolveNodeHidAddon } from "./standalone/resolve-node-hid-addon.js"; + +const targetByHost = { + "darwin-arm64": "bun-darwin-arm64", + "darwin-x64": "bun-darwin-x64", + "linux-arm64": "bun-linux-arm64", + "linux-x64": "bun-linux-x64-baseline", + "win32-x64": "bun-windows-x64-baseline", +}; + +const pinnedCompilerByTarget = { + "bun-linux-x64-baseline": "node_modules/@oven/bun-linux-x64-baseline/bin/bun", + "bun-windows-x64-baseline": + "node_modules/@oven/bun-windows-x64-baseline/bin/bun.exe", +}; + +function option(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const host = `${process.platform}-${process.arch}`; +const expectedTarget = targetByHost[host]; +if (!expectedTarget) { + throw new Error(`standalone builds are not supported on ${host}`); +} + +const target = option("--target") ?? expectedTarget; +if (target !== expectedTarget) { + throw new Error( + `refusing to combine ${host} native HID code with ${target}; build each target on its native runner`, + ); +} + +const defaultName = process.platform === "win32" ? "wallet-cli.exe" : "wallet-cli"; +const requestedOutfile = resolve(option("--outfile") ?? join("standalone", defaultName)); +const outfile = + process.platform === "win32" && extname(requestedOutfile).toLowerCase() !== ".exe" + ? `${requestedOutfile}.exe` + : requestedOutfile; +const outfileExt = extname(outfile); +const outfileStem = outfileExt ? outfile.slice(0, -outfileExt.length) : outfile; +const stagedOutfile = `${outfileStem}.${process.pid}.${Date.now()}.building${outfileExt}`; +const compileExecutable = resolveCompileExecutable(target); +const { nativeAddon, nodeHidVersion, transportPackageJson } = resolveNodeHidAddon(); +console.log( + `using node-hid ${nodeHidVersion} resolved from ${transportPackageJson}: ${nativeAddon}`, +); + +mkdirSync(dirname(outfile), { recursive: true }); + +const compile = { + target, + outfile: stagedOutfile, + autoloadDotenv: false, + autoloadBunfig: false, +}; +if (compileExecutable) { + compile.executablePath = compileExecutable; + console.log(`using pinned Bun compiler executable ${compileExecutable}`); +} + +const hidShim = resolve("scripts/standalone/node-hid.ts"); + +try { + const result = await Bun.build({ + entrypoints: [resolve("src/index.ts")], + compile, + minify: true, + plugins: [ + { + name: "standalone-node-hid", + setup(build) { + build.onResolve({ filter: /^node-hid$/ }, () => ({ path: hidShim })); + build.onResolve({ filter: /^wallet-cli-native-node-hid$/ }, () => ({ + path: nativeAddon, + })); + }, + }, + ], + }); + + if (!result.success) { + for (const log of result.logs) console.error(log); + process.exitCode = 1; + } else { + // Bun emits an ad-hoc-signed Mach-O, but embedding the native HID payload can leave that + // signature invalid. Sign the staged bytes before publishing so the previous executable remains + // usable if signing fails. + if (process.platform === "darwin") { + const signed = spawnSync("/usr/bin/codesign", ["--force", "--sign", "-", stagedOutfile], { + stdio: "inherit", + }); + if (signed.error) throw signed.error; + if (signed.status !== 0) { + throw new Error(`codesign failed for ${stagedOutfile} with status ${signed.status}`); + } + } + await publishExecutable(stagedOutfile, outfile); + console.log(`built ${outfile} (${target})`); + } +} finally { + // Failed compile/sign/publish attempts must not accumulate large embedded-runtime executables. + try { + if (existsSync(stagedOutfile)) unlinkSync(stagedOutfile); + } catch { + // Preserve the primary build error; a stale, uniquely named staging file is safe to remove later. + } +} + +function resolveCompileExecutable(target) { + const configured = process.env.BUN_COMPILE_EXECUTABLE?.trim(); + if (configured) { + const executable = resolve(configured); + if (!existsSync(executable)) { + throw new Error(`BUN_COMPILE_EXECUTABLE does not exist: ${executable}`); + } + return executable; + } + + const pinnedCompiler = pinnedCompilerByTarget[target]; + if (!pinnedCompiler) return undefined; + + const executable = resolve(pinnedCompiler); + if (!existsSync(executable)) { + throw new Error( + `missing pinned compiler ${pinnedCompiler}; run npm ci without --omit=optional`, + ); + } + return executable; +} + +async function publishExecutable(staged, destination) { + const attempts = process.platform === "win32" ? 12 : 1; + let lastError; + + for (let attempt = 0; attempt < attempts; attempt++) { + try { + renameSync(staged, destination); + return; + } catch (error) { + lastError = error; + } + + // Windows cannot rename over an existing executable on every filesystem/runtime combination. + // Remove only after the new executable has built successfully; a running image remains locked + // and leaves the old destination intact. + if (process.platform === "win32" && existsSync(destination)) { + try { + unlinkSync(destination); + renameSync(staged, destination); + return; + } catch (error) { + lastError = error; + } + } + + if (!isRetryableWindowsLock(lastError) || attempt === attempts - 1) break; + await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.min(50 * 2 ** attempt, 500))); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + const executableName = destination.split(/[\\/]/).at(-1); + const hint = + process.platform === "win32" + ? ` Close every running ${executableName} process and retry; ` + + `inspect locks with: tasklist /FI "IMAGENAME eq ${executableName}".` + : ""; + throw new Error(`failed to publish standalone executable to ${destination}${detail}.${hint}`, { + cause: lastError, + }); +} + +function isRetryableWindowsLock(error) { + if (process.platform !== "win32" || typeof error !== "object" || error === null) return false; + return new Set(["EPERM", "EACCES", "EBUSY", "EEXIST", "ENOTEMPTY"]).has(error.code); +} diff --git a/ts/scripts/standalone/node-hid-runtime.ts b/ts/scripts/standalone/node-hid-runtime.ts new file mode 100644 index 000000000..9119fb1bd --- /dev/null +++ b/ts/scripts/standalone/node-hid-runtime.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from "node:events"; + +interface NativeHid { + close(): void; + read(callback: (error: Error | null, data?: number[]) => void): void; +} + +interface NativeBinding { + HID: new (...args: unknown[]) => NativeHid; + devices(...args: unknown[]): unknown[]; +} + +/** + * Build the small JavaScript facade exposed by `node-hid`. + * + * `node-hid` normally locates its N-API addon through the dynamic `bindings()` helper. A Bun + * executable has no package directory at runtime, so that lookup cannot work. The build plugin + * resolves the addon from Ledger transport's package location and redirects the shim's static + * native import to that exact file; Bun can then embed and extract it with the executable. + */ +export function createNodeHid(binding: NativeBinding) { + class HID extends EventEmitter { + private readonly raw: NativeHid; + private paused = true; + private closing = false; + private closed = false; + + constructor(...args: unknown[]) { + super(); + this.raw = new binding.HID(...args); + + // Preserve node-hid's public surface without maintaining a second list of native methods. + for (const method in binding.HID.prototype) { + if (method in this) continue; + (this as Record)[method] = ( + binding.HID.prototype as unknown as Record unknown> + )[method]!.bind(this.raw); + } + + this.on("newListener", (eventName) => { + if (eventName === "data") process.nextTick(() => this.resume()); + }); + } + + close(): void { + this.closing = true; + this.removeAllListeners(); + this.raw.close(); + this.closed = true; + } + + pause(): void { + this.paused = true; + } + + read(callback: (error: Error | null, data?: number[]) => void): void { + if (this.closed) throw new Error("Unable to read from a closed HID device"); + this.raw.read(callback); + } + + resume(): void { + if (!this.paused || this.listenerCount("data") === 0) return; + this.paused = false; + const readNext = (error: Error | null, data?: number[]): void => { + if (error) { + this.paused = true; + if (!this.closing) this.emit("error", error); + return; + } + if (this.listenerCount("data") === 0) this.paused = true; + if (!this.paused) this.read(readNext); + this.emit("data", data); + }; + this.read(readNext); + } + } + + return { + HID, + devices: (...args: unknown[]) => binding.devices(...args), + // The standalone Linux build statically selects hidraw; keep this method for API compatibility. + setDriverType: (_type: string) => {}, + }; +} diff --git a/ts/scripts/standalone/node-hid.ts b/ts/scripts/standalone/node-hid.ts new file mode 100644 index 000000000..be9d5d366 --- /dev/null +++ b/ts/scripts/standalone/node-hid.ts @@ -0,0 +1,8 @@ +import { createNodeHid } from "./node-hid-runtime.js"; + +// The standalone build plugin resolves this static import from Ledger transport's package path. +// Bun sees the resulting `.node` file during bundling and embeds the exact addon used by transport. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const binding = require("wallet-cli-native-node-hid"); + +export default createNodeHid(binding); diff --git a/ts/scripts/standalone/resolve-node-hid-addon.ts b/ts/scripts/standalone/resolve-node-hid-addon.ts new file mode 100644 index 000000000..cfa46f721 --- /dev/null +++ b/ts/scripts/standalone/resolve-node-hid-addon.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +const TRANSPORT_PACKAGE = "@ledgerhq/hw-transport-node-hid-noevents"; +const SUPPORTED_NODE_HID_MAJOR = 2; + +interface ResolveNodeHidAddonOptions { + platform?: NodeJS.Platform; + rootUrl?: string | URL; +} + +interface ResolvedNodeHidAddon { + nativeAddon: string; + nodeHidVersion: string; + transportPackageJson: string; +} + +/** Resolve the native addon through the exact dependency tree visible to Ledger transport. */ +export function resolveNodeHidAddon( + options: ResolveNodeHidAddonOptions = {}, +): ResolvedNodeHidAddon { + const { platform = process.platform, rootUrl = import.meta.url } = options; + const rootRequire = createRequire(rootUrl); + const transportPackageJson = rootRequire.resolve(`${TRANSPORT_PACKAGE}/package.json`); + const transportRequire = createRequire(transportPackageJson); + const nodeHidPackageJson = transportRequire.resolve("node-hid/package.json"); + const packageJson = JSON.parse(readFileSync(nodeHidPackageJson, "utf8")) as { + version?: unknown; + }; + + if (typeof packageJson.version !== "string") { + throw new Error(`node-hid package has no valid version: ${nodeHidPackageJson}`); + } + const nodeHidVersion = packageJson.version; + const nodeHidMajor = Number.parseInt(nodeHidVersion.split(".", 1)[0]!, 10); + if (nodeHidMajor !== SUPPORTED_NODE_HID_MAJOR) { + throw new Error( + `${TRANSPORT_PACKAGE} resolved unsupported node-hid ${nodeHidVersion}; ` + + `review scripts/standalone/node-hid-runtime.ts before updating the native addon`, + ); + } + + const addonName = platform === "linux" ? "HID_hidraw.node" : "HID.node"; + const addonRequest = `node-hid/build/Release/${addonName}`; + let nativeAddon: string; + try { + nativeAddon = transportRequire.resolve(addonRequest); + } catch (cause) { + throw new Error( + `unable to resolve ${addonRequest} from ${transportPackageJson}; ` + + `run npm ci on the target platform without --omit=optional`, + { cause }, + ); + } + + return { nativeAddon, nodeHidVersion, transportPackageJson }; +} diff --git a/ts/scripts/verify-standalone.mjs b/ts/scripts/verify-standalone.mjs new file mode 100644 index 000000000..ff2b4678a --- /dev/null +++ b/ts/scripts/verify-standalone.mjs @@ -0,0 +1,68 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; + +const executable = process.argv[2] ? resolve(process.argv[2]) : undefined; +const expectedVersion = + process.argv[3] ?? JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; + +if (!executable) { + throw new Error("usage: node scripts/verify-standalone.mjs EXECUTABLE [EXPECTED_VERSION]"); +} + +function run(args, env = process.env) { + return spawnSync(executable, args, { + encoding: "utf8", + env, + windowsHide: true, + }); +} + +const version = run(["--version"]); +if (version.status !== 0 || version.stdout.trim() !== expectedVersion) { + throw new Error( + `version smoke test failed: status=${version.status}, stdout=${JSON.stringify(version.stdout)}, stderr=${JSON.stringify(version.stderr)}`, + ); +} + +const help = run(["--help"]); +if (help.status !== 0 || !help.stdout.includes("Usage: wallet-cli")) { + throw new Error( + `help smoke test failed: status=${help.status}, stdout=${JSON.stringify(help.stdout)}, stderr=${JSON.stringify(help.stderr)}`, + ); +} + +// Loading the Ledger command forces the embedded node-hid N-API addon to load. CI normally reaches +// `NoDevice`; a developer machine may instead enumerate a busy Ledger or complete the import. All +// three outcomes prove the addon loaded, while binding/dynamic-library failures remain rejected. +const isolatedHome = mkdtempSync(join(tmpdir(), "wallet-cli-standalone-")); +try { + const ledger = run( + ["import", "ledger", "--app", "tron", "--index", "0", "--output", "json"], + { ...process.env, WALLET_CLI_HOME: isolatedHome }, + ); + const output = `${ledger.stdout}\n${ledger.stderr}`; + let result; + try { + result = JSON.parse(ledger.stdout); + } catch { + // The failure below includes the original output for diagnosis. + } + const imported = ledger.status === 0 && result?.success === true; + const expectedDeviceError = + ledger.status === 1 && + result?.success === false && + typeof result?.error?.message === "string" && + /NoDevice|cannot open device with path/.test(result.error.message); + if (!imported && !expectedDeviceError) { + throw new Error( + `Ledger native-addon smoke test failed: status=${ledger.status}, output=${JSON.stringify(output)}`, + ); + } +} finally { + rmSync(isolatedHome, { recursive: true, force: true }); +} + +console.log(`verified ${executable} (${expectedVersion})`); diff --git a/ts/skills/wallet-cli/SKILL.md b/ts/skills/wallet-cli/SKILL.md index f3f73f0a6..4d27a0fbd 100644 --- a/ts/skills/wallet-cli/SKILL.md +++ b/ts/skills/wallet-cli/SKILL.md @@ -25,17 +25,33 @@ create --label new HD wallet (BIP39) import mnemonic|private-key|ledger|watch bring in existing accounts list / use / current enumerate & select active account derive / rename / backup / delete account lifecycle (backup: secret, file mode 0600) +address generate keypair made locally; NOT added to the wallet account balance|info|history|portfolio on-chain state (history needs TronGrid) -tx send --to --amount TRX; add --token SYM | --contract Txx | --asset-id N for tokens - [--dry-run | --sign-only] estimate only / sign without broadcast -tx broadcast --tx-stdin broadcast a presigned tx +account activate --address activate a new account (payer = active account) +account set --name | --id one-time on-chain name/ID — effectively immutable +tx send --to --amount TRX; add --token SYM | --contract Txx | --asset-id N for tokens + [--dry-run|--sign-only|--build-only] estimate only / sign / build unsigned, no broadcast +tx sign --transaction sign JSON built elsewhere +tx sign --file [--check] [--out] append a signature to a multi-sig artifact (--check verifies online) +tx approvals --file who signed, weight so far, missing weight, expiry +tx multisig [--create|--sign |--watch] collect signatures via the TronLink service +tx broadcast --tx-stdin|--hex|--file broadcast a presigned tx; --dry-run validates only tx status --txid state: confirmed|failed|pending|not_found tx info --txid full detail + receipt +permission show owner/witness/active groups, thresholds, decoded ops +permission update --file replace the WHOLE permission structure (lockout risk) +gasfree info|transfer|trace send tokens with no TRX; fee paid in the token stake freeze|unfreeze|withdraw|cancel-unfreeze|delegate|undelegate resource staking token / contract / message / block address book, smart contracts, signing, blocks +contact add|list|remove recipient names usable as --to +encoding convert base58 / 41-hex / 0x-EVM / hex / base64 conversions config / networks local config, known networks ``` +Multi-sig: build unsigned (`--build-only`) → each signer `tx sign --file … --out` → check with +`tx approvals` → `tx broadcast` once `thresholdReached` is true. `tx broadcast` refuses a +transaction below its threshold, so branch on `data.thresholdReached`, never on exit code alone. + Details for any command: `wallet-cli --help`. ## Transaction safety (mandatory) @@ -46,7 +62,11 @@ Details for any command: `wallet-cli --help`. ## Dangerous commands — require explicit user confirmation -`tx send` / `tx broadcast` / `contract send|deploy` on `tron:mainnet` (moves funds) · `delete` (removes accounts; HD delete cascades from the seed root) · `backup` (writes secret material to disk). +`tx send` / `tx broadcast` / `contract send|deploy` / `gasfree transfer` on `tron:mainnet` (moves funds) · `delete` (removes accounts; HD delete cascades from the seed root) · `backup` (writes secret material to disk) · `address generate --print-secret` (writes a private key to stdout). + +**`permission update` is the most dangerous command in the CLI** — it replaces the account's entire permission structure, and a structure whose owner group excludes your keys locks the account permanently, with no recovery. Never run it without `--dry-run` first and explicit user confirmation of the rendered structure. Heed `owner_lockout` / `owner_lockout_partial` warnings. + +`account activate` and `account set` are one-shot: an account activates once, and the on-chain name and ID can each be set once. ## Error handling diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 8d3659562..9e53e2bc7 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -3,6 +3,98 @@ import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronAccountService } from "../../../../application/use-cases/tron/account-service.js"; import { ciEnum } from "../arity/index.js"; import { TextFormatters } from "../render/index.js"; +import { txModeFields } from "./shared.js"; + +const transactionModeRefine = ( + input: { + dryRun?: boolean; + signOnly?: boolean; + buildOnly?: boolean; + expiration?: number; + }, + context: z.RefinementCtx, +): void => { + if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { + context.addIssue({ + code: "custom", + path: ["dryRun"], + message: "choose at most one of --dry-run, --sign-only, --build-only", + }); + } + if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { + context.addIssue({ + code: "custom", + path: ["expiration"], + message: "--expiration is only valid with --sign-only or --build-only", + }); + } +}; + +export const accountActivateSpec: ChainSpec = { + path: ["account", "activate"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "account.activate", + summary: "Activate a new TRON account", + description: + "Create an AccountCreateContract funded by the active account. The target must not already be active; use --dry-run to inspect current creation fees.", + baseFields: z.object({ + address: z.string().min(1).describe("unactivated TRON base58 address"), + ...txModeFields, + }), + baseRefine: transactionModeRefine, + examples: [ + { cmd: "wallet-cli account activate --address T... --dry-run" }, + { cmd: "wallet-cli account activate --address T... --wait --password-stdin" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const accountActivateTronBinding = ( + service: TronAccountService, +): FamilyBinding => ({ + run: async (ctx, network, input) => service.activate(ctx, network, input), +}); + +export const accountSetSpec: ChainSpec = { + path: ["account", "set"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "account.set", + summary: "Set the one-time on-chain account name or ID", + description: + "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32 UTF-8 bytes.", + baseFields: z.object({ + name: z.string().min(1).optional().describe("one-time on-chain account name (1-32 UTF-8 bytes)"), + id: z.string().min(1).optional().describe("one-time unique account ID (8-32 UTF-8 bytes)"), + ...txModeFields, + }), + baseRefine: (input, context) => { + if ((input.name === undefined) === (input.id === undefined)) { + context.addIssue({ + code: "custom", + path: ["name"], + message: "provide exactly one of --name or --id", + }); + } + transactionModeRefine(input, context); + }, + examples: [ + { cmd: "wallet-cli account set --name alice --dry-run" }, + { cmd: "wallet-cli account set --id alice-001 --wait --password-stdin" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const accountSetTronBinding = ( + service: TronAccountService, +): FamilyBinding => ({ + run: async (ctx, network, input) => service.setOnChain(ctx, network, input), +}); export const accountBalanceSpec: ChainSpec = { path: ["account", "balance"], diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts new file mode 100644 index 000000000..f7e6fa435 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { AddressService } from "../../../../application/use-cases/address-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerAddressCommands( + registry: CommandRegistry, + service: AddressService, +): void { + const fields = z.object({ + out: z.string().min(1).max(4096).optional() + .describe("exclusive 0600 output path; existing files are never overwritten"), + printSecret: z.boolean().default(false) + .describe("print the private key instead of writing it; use only offline"), + }); + registry.add({ + path: ["address", "generate"], + network: "none", + wallet: "none", + auth: "none", + summary: + "Generate a random TRON/EVM keypair locally without adding it to the wallet", + description: + "Generate a secp256k1 keypair offline. By default the private key is written exclusively to a 0600 file and never printed or added to the keystore.", + fields, + input: fields, + examples: [ + { cmd: "wallet-cli address generate" }, + { + cmd: "wallet-cli address generate --out /secure/usb/key.json", + }, + ], + formatText: TextFormatters.addressGenerate, + run: async (_context, _network, input) => service.generate(input), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/artifact.ts b/ts/src/adapters/inbound/cli/commands/artifact.ts new file mode 100644 index 000000000..689d87280 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/artifact.ts @@ -0,0 +1,24 @@ +import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; +import { UsageError } from "../../../../domain/errors/index.js"; + +export function readBoundedTextFile(path: string, maxBytes: number, label: string): string { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const stat = fstatSync(fd); + if (!stat.isFile()) throw new UsageError("invalid_value", `${label} must be a regular file`); + if (stat.size > maxBytes) throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); + return readFileSync(fd, "utf8"); + } catch (error) { + if (error instanceof UsageError) throw error; + throw new UsageError("invalid_value", `could not read ${label}: ${(error as Error).message}`); + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +export function exactlyOne(values: readonly unknown[], message: string): void { + if (values.filter((value) => value !== undefined && value !== false).length !== 1) { + throw new UsageError("invalid_option", message); + } +} diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts new file mode 100644 index 000000000..218271339 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { ContactService } from "../../../../application/use-cases/contact-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerContactCommands( + registry: CommandRegistry, + service: ContactService, +): void { + const addFields = z.object({ + name: z.string().min(1).max(256), + address: z.string().min(1).max(128), + note: z.string().max(512).optional() + .describe("free-form note, up to 128 safe characters"), + }); + registry.add({ + path: ["contact", "add"], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "name" }, { field: "address" }], + summary: "Add a recipient", + description: + "Add a locally stored TRON recipient. The Base58Check address is validated and the name can then be used by tx send and gasfree transfer.", + fields: addFields, + input: addFields, + examples: [{ + cmd: "wallet-cli contact add alice TBy6... --note 'Alice mainnet'", + }], + formatText: TextFormatters.contactAdd, + run: async (_context, _network, input) => + service.add(input.name, input.address, input.note), + } satisfies CommandDefinition); + + const empty = z.object({}); + registry.add({ + path: ["contact", "list"], + network: "none", + wallet: "none", + auth: "none", + summary: "List recipients", + description: + "List every recipient in the local plaintext address book.", + fields: empty, + input: empty, + examples: [{ cmd: "wallet-cli contact list" }], + formatText: TextFormatters.contactList, + run: async () => service.list(), + } satisfies CommandDefinition); + + const removeFields = z.object({ + name: z.string().min(1).max(256), + }); + registry.add({ + path: ["contact", "remove"], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "name" }], + summary: "Remove a recipient", + description: + "Remove one recipient from the local address book without changing any on-chain state.", + fields: removeFields, + input: removeFields, + examples: [{ cmd: "wallet-cli contact remove alice" }], + formatText: TextFormatters.contactRemove, + run: async (_context, _network, input) => service.remove(input.name), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index b20bbcf77..153e7d63a 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -75,7 +75,7 @@ const sendFields = z.object({ export const contractSendSpec: ChainSpec = { path: ["contract", "send"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "contract.call", summary: "State-changing call (triggerSmartContract)", @@ -104,7 +104,7 @@ const deployFields = z.object({ export const contractDeploySpec: ChainSpec = { path: ["contract", "deploy"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "contract.deploy", summary: "Deploy a smart contract", diff --git a/ts/src/adapters/inbound/cli/commands/encoding.ts b/ts/src/adapters/inbound/cli/commands/encoding.ts new file mode 100644 index 000000000..749462795 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/encoding.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { EncodingService } from "../../../../application/use-cases/encoding-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerEncodingCommands( + registry: CommandRegistry, + service: EncodingService, +): void { + const fields = z.object({ + input: z.string().min(1).max(2 * 1024 * 1024), + }); + registry.add({ + path: ["encoding", "convert"], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "input" }], + summary: + "Convert and validate address, hex, Base64, and Base58Check encodings", + description: + "Auto-detect an address/public-key or generic encoding and print all equivalent forms. Runs locally; 32-byte private-key-shaped values are rejected from argv.", + fields, + input: fields, + examples: [ + { cmd: "wallet-cli encoding convert TBhC..." }, + { cmd: "wallet-cli encoding convert deadbeef0102" }, + ], + formatText: TextFormatters.encodingConvert, + run: async (_context, _network, input) => + service.convert(input.input), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts new file mode 100644 index 000000000..c425f2b2b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { GasFreeService } from "../../../../application/use-cases/tron/gasfree-service.js"; +import { TextFormatters } from "../render/index.js"; + +export const gasFreeInfoSpec: ChainSpec = { + path: ["gasfree", "info"], + network: "optional", + wallet: "optional", + auth: "none", + capability: "gasfree.info", + requires: [ + "config gasfreeApiKey / gasfreeApiSecret", + ], + summary: "Show GasFree address, activation status, nonce, balances, and fees", + description: + "Show this account's GasFree address, activation status, nonce, supported tokens, balances, and current token-denominated fees.", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli gasfree info" }], + formatText: TextFormatters.gasFreeInfo, +}; + +export const gasFreeInfoTronBinding = ( + service: GasFreeService, +): FamilyBinding => ({ + run: async (context, network) => service.info(context, network), +}); + +const transferFields = z.object({ + to: z.string().trim().min(1).max(128) + .describe("recipient TRON address or local contact name"), + amount: z.string().regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") + .refine( + (value) => !/^0+(\.0+)?$/.test(value), + "must be greater than zero", + ), + token: z.string().trim().min(1).max(32).default("USDT"), + dryRun: z.boolean().default(false), +}); + +export const gasFreeTransferSpec: ChainSpec = { + path: ["gasfree", "transfer"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "gasfree.transfer", + requires: [ + "config gasfreeApiKey / gasfreeApiSecret", + ], + summary: "Sign and submit a TIP-712 GasFree token transfer", + description: + "Sign a GasFree PermitTransfer and submit it to the provider. No TRX is needed; --dry-run checks the token balance and fee breakdown without unlocking or signing.", + baseFields: transferFields, + baseRefine: (value, context) => { + if (value.dryRun && value.wait) { + context.addIssue({ + code: "custom", + path: ["dryRun"], + message: "--dry-run cannot be combined with --wait", + }); + } + }, + examples: [ + { + cmd: "wallet-cli gasfree transfer --to T... --amount 25 --password-stdin", + }, + { + cmd: "wallet-cli gasfree transfer --to alice --amount 25 --wait --password-stdin", + }, + ], + formatText: TextFormatters.gasFreeTransfer, +}; + +export const gasFreeTransferTronBinding = ( + service: GasFreeService, +): FamilyBinding => ({ + run: async (context, network, input) => + service.transfer(context, network, input), +}); + +const traceFields = z.object({ + traceId: z.string().trim().min(1).max(128) + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/), +}); + +export const gasFreeTraceSpec: ChainSpec = { + path: ["gasfree", "trace"], + network: "optional", + wallet: "none", + auth: "none", + capability: "gasfree.trace", + requires: [ + "config gasfreeApiKey / gasfreeApiSecret", + ], + positionals: [{ field: "traceId", placeholder: "traceId" }], + summary: "Track a GasFree transfer by provider trace id", + description: + "Track a submitted GasFree transfer through WAITING, INPROGRESS, CONFIRMING, and the SUCCEED or FAILED terminal state.", + baseFields: traceFields, + examples: [ + { + cmd: "wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527", + }, + ], + formatText: TextFormatters.gasFreeTrace, +}; + +export const gasFreeTraceTronBinding = ( + service: GasFreeService, +): FamilyBinding => ({ + run: async (_context, network, input) => + service.trace(network, input.traceId), +}); diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts new file mode 100644 index 000000000..b71901829 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -0,0 +1,96 @@ +import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; +import { z } from "zod"; +import type { TronPermissionService } from "../../../../application/use-cases/tron/permission-service.js"; +import { UsageError } from "../../../../domain/errors/index.js"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import { TextFormatters } from "../render/index.js"; +import { exactlyOne, readBoundedTextFile } from "./artifact.js"; + +const showFields = z.object({}); + +export const permissionShowSpec: ChainSpec = { + path: ["permission", "show"], + network: "optional", + wallet: "optional", + auth: "none", + capability: "permission.read", + summary: "Show owner, witness, and active permission groups", + description: "Show thresholds, authorized keys, and decoded operation bitmaps. --account may be a local account or any activated TRON address.", + baseFields: showFields, + examples: [ + { cmd: "wallet-cli permission show" }, + { cmd: "wallet-cli permission show --account T... -o json" }, + ], + formatText: TextFormatters.permissionShow, +}; + +const updateFields = z.object({ + file: z.string().min(1).optional().describe("complete replacement permission JSON file"), + json: z.string().min(1).optional().describe("inline complete replacement permission JSON"), + dryRun: z.boolean().default(false).describe("validate, build, and estimate without signing or broadcasting"), + signOnly: z.boolean().default(false).describe("build and sign, then output complete transaction hex"), + buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), + permissionId: z.coerce.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), + expiration: z.coerce.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), +}); + +export const permissionUpdateSpec: ChainSpec = { + path: ["permission", "update"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "permission.update", + summary: "Replace the complete account permission structure", + description: "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. Misconfiguration can permanently lock the account; use --dry-run first.", + baseFields: updateFields, + baseRefine: (input, context) => { + if ([input.file, input.json].filter((value) => value !== undefined).length !== 1) { + context.addIssue({ code: "custom", path: ["file"], message: "provide exactly one of --file or --json" }); + } + if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { + context.addIssue({ code: "custom", path: ["dryRun"], message: "choose at most one of --dry-run, --sign-only, --build-only" }); + } + if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { + context.addIssue({ code: "custom", path: ["expiration"], message: "--expiration is only valid with --sign-only or --build-only" }); + } + }, + examples: [ + { cmd: "wallet-cli permission update --file permissions.json --dry-run" }, + { cmd: "wallet-cli permission update --file permissions.json --wait --password-stdin" }, + ], + formatText: TextFormatters.permissionUpdate, +}; + +export const permissionShowTronBinding = (service: TronPermissionService): FamilyBinding => ({ + run: async (ctx, network) => service.show(network, ctx.resolveAddress("tron")), +}); + +export const permissionUpdateTronBinding = (service: TronPermissionService): FamilyBinding => ({ + run: async (ctx, network, input) => { + exactlyOne([input.file, input.json], "provide exactly one of --file or --json"); + const raw = input.file + ? readBoundedTextFile(input.file, 1024 * 1024, "permission JSON file") + : input.json; + let parsed: unknown; + try { + parsed = normalizeLossless(parseLosslessJson(raw)); + } catch { + throw new UsageError("invalid_permission", "permission structure must be valid JSON"); + } + return service.update(ctx, network, input, parsed); + }, +}); + +function normalizeLossless(value: unknown): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + const number = Number(exact); + return Number.isSafeInteger(number) ? number : exact; + } + if (Array.isArray(value)) return value.map(normalizeLossless); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeLossless(entry)])); + } + return value; +} diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts index 75bd1e8d4..7cf79373d 100644 --- a/ts/src/adapters/inbound/cli/commands/reward.ts +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -24,7 +24,7 @@ export const rewardBalanceTronBinding = (svc: TronRewardService): FamilyBinding export const rewardWithdrawSpec: ChainSpec = { path: ["reward", "withdraw"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "reward.withdraw", summary: "Withdraw accrued voting/block rewards", diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 366969683..2cbacaeda 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -10,10 +10,13 @@ import { TextFormatters } from "../render/index.js"; import type { MessageService } from "../../../../application/use-cases/message-service.js"; // ── execution-mode flags shared by every signing command ───────────────────────── -/** dry-run / sign-only fields; default (no flag) = sign AND broadcast on-chain. */ +/** Transaction execution fields; default (no mode flag) = sign and broadcast on-chain. */ export const txModeFields = { - dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast; mutually exclusive with --sign-only"), - signOnly: z.boolean().default(false).describe("sign and output the transaction without broadcasting; mutually exclusive with --dry-run; broadcast later with tx broadcast"), + dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast"), + signOnly: z.boolean().default(false).describe("sign and output complete transaction hex without broadcasting"), + buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), + permissionId: z.coerce.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), + expiration: z.coerce.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), }; // ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── // A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index 44b1873e3..faa82c78f 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -38,7 +38,7 @@ function stakeCommand( return { spec: { path: ["stake", action], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: options.capability ?? "staking.freeze", summary, diff --git a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts index 558b1e284..bbc8f7afd 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -9,6 +9,9 @@ import { TextFormatters } from "../render/index.js"; import { isChainCommand } from "../contracts/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import type { ConfigService } from "../../../../application/use-cases/config-service.js"; +import { registerContactCommands } from "./contact.js"; +import { registerAddressCommands } from "./address.js"; +import { registerEncodingCommands } from "./encoding.js"; const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", ...over }); @@ -18,6 +21,9 @@ describe("text formatters", () => { registerWalletCommands(registry, {} as Parameters[1]); registerConfigCommands(registry, {} as ConfigService); registerNetworkCommands(registry); + registerContactCommands(registry, {} as never); + registerAddressCommands(registry, {} as never); + registerEncodingCommands(registry, {} as never); registerTronChainCommands(registry, {} as TronChainCommandDependencies); const missing = registry.all() @@ -46,6 +52,27 @@ describe("accountBalance formatter", () => { }); }); +describe("walletCurrent formatter", () => { + it("renders a receive QR followed by the full address for manual verification", () => { + const out = TextFormatters.walletCurrent({ + accountId: "wlt_selected", + label: "treasury", + active: false, + addresses: { + tron: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + }, + receiveQr: "█▀█\n▀▄▀", + receiveAddress: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + }); + + expect(out).toContain("Selected account: treasury"); + expect(out).toContain("█▀█\n▀▄▀"); + expect(out).toMatch( + /█▀█\n▀▄▀\nReceive address\s+TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC/, + ); + }); +}); + describe("stake/chain TRX amount formatting", () => { it("groups the integer part without truncating fractional TRX", () => { const stake = TextFormatters.stakeDelegated({ @@ -198,6 +225,95 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); }); +describe("local multisig formatters", () => { + const approval = { + txId: "abc123", + contractType: "TransferContract", + operation: "TransferContract", + from: "Towner", + to: "Trecipient", + rawAmount: "1000000", + permission: { id: 2, name: "operations", threshold: 2 }, + currentWeight: 1, + missingWeight: 1, + thresholdReached: false, + approved: [{ address: "Tsigner", weight: 1 }], + expiration: Date.now() + 60_000, + expired: false, + signatures: 1, + }; + + it("shows permission progress and approved signer weight", () => { + const out = TextFormatters.txApprovals(approval) as string; + expect(out).toContain('active "operations" (id 2)'); + expect(out).toContain("Progress 1 / 2"); + expect(out).toContain("1 more weight needed"); + expect(out).toContain("Tsigner"); + }); + + it("shows the next broadcast command only after threshold is reached", () => { + const transaction = { + txId: approval.txId, + contractType: approval.contractType, + operation: approval.operation, + from: approval.from, + to: approval.to, + rawAmount: approval.rawAmount, + permissionId: approval.permission.id, + expiration: approval.expiration, + expired: approval.expired, + signatures: approval.signatures, + }; + const pending = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner", + checked: true, + signerWeight: 1, + hex: "aabb", + transaction, + approval, + }) as string; + expect(pending).not.toContain("wallet-cli tx broadcast"); + + const ready = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner2", + checked: true, + signerWeight: 1, + hex: "ccdd", + out: "signed.hex", + transaction: { ...transaction, signatures: 2 }, + approval: { + ...approval, + currentWeight: 2, + missingWeight: 0, + thresholdReached: true, + }, + }) as string; + expect(ready).toContain("wallet-cli tx broadcast --file signed.hex"); + }); + + it("labels offline signing and points to the explicit approval check", () => { + const out = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner", + checked: false, + hex: "aabb", + transaction: { + txId: approval.txId, + contractType: approval.contractType, + permissionId: approval.permission.id, + expiration: approval.expiration, + expired: false, + signatures: 1, + }, + }) as string; + expect(out).toContain("Approval state was not checked online"); + expect(out).toContain("wallet-cli tx approvals --hex "); + expect(out).not.toContain("weight"); + }); +}); + describe("txStatus formatter (family-agnostic; command supplies `state`)", () => { it("tron: confirmed when not failed", () => { const out = TextFormatters.txStatus({ txid: "abc", state: "confirmed", confirmed: true, failed: false, blockNumber: 123 }); diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts new file mode 100644 index 000000000..83e12e749 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { permissionUpdateSpec } from "./permission.js"; +import { txModeFields } from "./shared.js"; + +describe("transaction option argv coercion", () => { + it("accepts numeric --permission-id and --expiration values from argv", () => { + const parsed = z.object(txModeFields).parse({ + buildOnly: true, + permissionId: "2", + expiration: "86400000", + }); + + expect(parsed.permissionId).toBe(2); + expect(parsed.expiration).toBe(86_400_000); + }); + + it("applies the same argv coercion to permission update", () => { + const parsed = permissionUpdateSpec.baseFields.parse({ + file: "permissions.json", + buildOnly: true, + permissionId: "2", + expiration: "60000", + }); + + expect(parsed.permissionId).toBe(2); + expect(parsed.expiration).toBe(60_000); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index abf4b125e..ecfcbb2e7 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { txSignSpec, txSignTronBinding } from "./tx.js"; +import { + txBroadcastSpec, + txBroadcastTronBinding, + txSignSpec, + txSignTronBinding, + txTronLinkMultisigSpec, +} from "./tx.js"; const ctx = { activeAccount: "main" } as never; const net = { family: "tron", id: "nile" } as never; @@ -11,9 +17,11 @@ describe("tx sign spec", () => { expect(txSignSpec.auth).toBe("required"); }); - it("requires a --transaction payload", () => { - expect(txSignSpec.baseFields.safeParse({}).success).toBe(false); + it("accepts the retained JSON input and the new hex/file inputs", () => { expect(txSignSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ hex: "abcd" }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ hex: "abcd", check: true }).success).toBe(true); }); // the payload is not a secret, so argv is the only channel — no --tx-stdin on this command. @@ -31,13 +39,117 @@ describe("tx sign binding", () => { return { kind: "sign" }; }, }; - await txSignTronBinding(svc as never).run(ctx, net, { transaction: '{"txID":"abc"}' }); + await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) + .run(ctx, net, { transaction: '{"txID":"abc"}' }); expect(received).toEqual({ txID: "abc" }); }); it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => ({}) }; - await expect(txSignTronBinding(svc as never).run(ctx, net, { transaction: "not json" })) + await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) + .run(ctx, net, { transaction: "not json" })) .rejects.toMatchObject({ code: "invalid_value" }); }); + + it("routes hex signing through the offline signing service and writes --out", async () => { + const signing = { + sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", checked: false, transaction: {} }), + }; + let written: unknown; + const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; + const result = await txSignTronBinding({} as never, signing as never, {} as never, writer as never) + .run(ctx, net, { hex: "abcd", out: "signed.hex" }); + expect(written).toEqual({ path: "signed.hex", hex: "beef" }); + expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); + }); + + it("routes --check through the multisig authorization service", async () => { + const signing = { sign: async () => { throw new Error("unexpected offline route"); } }; + const multisig = { + signChecked: async () => ({ + kind: "tx-sign", + hex: "beef", + signer: "T1", + checked: true, + signerWeight: 1, + transaction: {}, + approval: {}, + }), + }; + await expect(txSignTronBinding({} as never, signing as never, multisig as never, {} as never) + .run(ctx, net, { hex: "abcd", check: true })) + .resolves.toMatchObject({ checked: true, signerWeight: 1 }); + }); +}); + +describe("tx broadcast binding", () => { + const broadcastContext = (stdin?: string, wait = false) => ({ + wait, + secrets: { + has: (kind: string) => kind === "tx" && stdin !== undefined, + pick: (inline: string | undefined) => inline ?? stdin, + }, + }) as never; + + it("retains JSON/stdin inputs and adds hex/file inputs", () => { + expect(txBroadcastSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); + expect(txBroadcastSpec.baseFields.safeParse({ hex: "abcd" }).success).toBe(true); + expect(txBroadcastSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); + }); + + it("routes protobuf hex without parsing it as JSON", async () => { + const service = { + broadcastHex: async (_ctx: unknown, _net: unknown, hex: string, dryRun: boolean) => ({ + hex, + dryRun, + }), + broadcastJson: async () => { + throw new Error("unexpected JSON route"); + }, + }; + await expect(txBroadcastTronBinding(service as never) + .run(broadcastContext(), net, { hex: "aabb", dryRun: true })) + .resolves.toEqual({ hex: "aabb", dryRun: true }); + }); + + it("routes the retained --tx-stdin JSON source", async () => { + let received: unknown; + const service = { + broadcastHex: async () => { + throw new Error("unexpected hex route"); + }, + broadcastJson: async (_ctx: unknown, _net: unknown, transaction: unknown) => { + received = transaction; + return { txId: "abc" }; + }, + }; + await txBroadcastTronBinding(service as never) + .run(broadcastContext('{"txID":"abc"}'), net, { dryRun: false }); + expect(received).toEqual({ txID: "abc" }); + }); + + it("rejects ambiguous input and --wait with --dry-run", async () => { + const service = { broadcastHex: async () => ({}), broadcastJson: async () => ({}) }; + await expect(txBroadcastTronBinding(service as never) + .run(broadcastContext(), net, { transaction: "{}", hex: "aabb", dryRun: false })) + .rejects.toMatchObject({ code: "invalid_option" }); + await expect(txBroadcastTronBinding(service as never) + .run(broadcastContext(undefined, true), net, { hex: "aabb", dryRun: true })) + .rejects.toMatchObject({ code: "invalid_option" }); + }); +}); + +describe("tx multisig spec", () => { + it("supports list, unsigned create, sign, and WebSocket watch modes", () => { + expect(txTronLinkMultisigSpec.baseFields.safeParse({}).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ create: true, hex: "aabb" }).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ sign: "ab".repeat(32) }).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ watch: true }).success).toBe(true); + }); + + it("declares no broadcast and only requires auth for --sign", () => { + expect(txTronLinkMultisigSpec.broadcasts).toBeFalsy(); + expect(txTronLinkMultisigSpec.auth).toBe("conditional"); + expect(txTronLinkMultisigSpec.passwordMode).toBeUndefined(); + }); }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 0a401a267..cbb4308c1 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { UsageError } from "../../../../domain/errors/index.js"; import type { TronTransactionService } from "../../../../application/use-cases/tron/transaction-service.js"; +import type { TronSigService } from "../../../../application/use-cases/tron/sig-service.js"; +import type { TronMultisigService } from "../../../../application/use-cases/tron/multisig-service.js"; +import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; +import type { TransactionArtifactWriter } from "../../../outbound/persistence/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; import { amountSelector, @@ -9,11 +13,13 @@ import { unifiedAmountFields, } from "./shared.js"; import { TextFormatters } from "../render/index.js"; +import { exactlyOne, readBoundedTextFile } from "./artifact.js"; // baseFields today (single family). When EVM lands, move feeLimit/assetId/contract into the TRON // binding.fields and put gasPrice/gasLimit/nonce into the EVM binding.fields (spec §4 base/delta). const sendFields = z.object({ - to: Schemas.addressFor("tron").describe("recipient TRON base58 address"), + to: z.string().trim().min(1).max(128) + .describe("recipient TRON base58 address or local contact name"), token: z.string().min(1).optional() .describe("token symbol from the address book; mutually exclusive with --contract and --asset-id"), contract: Schemas.addressFor("tron").optional() @@ -31,7 +37,7 @@ const sendFields = z.object({ export const txSendSpec: ChainSpec = { path: ["tx", "send"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "tx.send", summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", @@ -53,7 +59,11 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => const broadcastFields = z.object({ transaction: z.string().optional() - .describe("signed TRON transaction JSON; provide this OR --tx-stdin; exactly one is required"), + .describe("signed TRON transaction JSON; mutually exclusive with --tx-stdin/--hex/--file"), + hex: z.string().min(2).optional().describe("complete signed protocol.Transaction hex"), + file: z.string().min(1).optional().describe("file containing complete signed protocol.Transaction hex"), + dryRun: z.boolean().default(false) + .describe("validate signatures, threshold, expiration, and dynamic multi-sign fee without broadcasting"), }); export const txBroadcastSpec: ChainSpec = { @@ -62,17 +72,41 @@ export const txBroadcastSpec: ChainSpec = { network: "optional", wallet: "none", auth: "none", broadcasts: true, capability: "tx.broadcast", - summary: "Broadcast a presigned transaction", + summary: "Validate and broadcast a presigned JSON or protobuf-hex transaction", baseFields: broadcastFields, - examples: [{ cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }], + baseRefine: (input, context) => { + if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { + context.addIssue({ + code: "custom", + path: ["transaction"], + message: "--transaction, --hex, and --file are mutually exclusive", + }); + } + }, + examples: [ + { cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }, + { cmd: "wallet-cli tx broadcast --file signed.hex" }, + ], formatText: TextFormatters.txReceipt, }; -export const txBroadcastTronBinding = (svc: TronTransactionService): FamilyBinding => ({ +export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBinding => ({ run: async (ctx, net, input) => { + if (input.dryRun && ctx.wait) { + throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); + } + const stdin = ctx.secrets.has("tx"); + exactlyOne( + [input.transaction, stdin ? true : undefined, input.hex, input.file], + "provide exactly one of --transaction, --tx-stdin, --hex, or --file", + ); + if (input.hex || input.file) { + const hex = input.hex ?? readBoundedTextFile(input.file, 1024 * 1024 + 4096, "transaction hex file"); + return service.broadcastHex(ctx, net, hex, input.dryRun); + } const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); try { - return svc.broadcast(ctx, net, JSON.parse(raw)); + return service.broadcastJson(ctx, net, JSON.parse(raw), input.dryRun); } catch (error) { if (error instanceof SyntaxError) { throw new UsageError("invalid_value", "TRON presigned tx must be JSON"); @@ -82,9 +116,36 @@ export const txBroadcastTronBinding = (svc: TronTransactionService): FamilyBindi }, }); +const artifactFields = { + hex: z.string().min(2).optional().describe("complete protocol.Transaction hex"), + file: z.string().min(1).optional().describe("file containing complete protocol.Transaction hex"), +}; + +const approvalsFields = z.object(artifactFields); + +export const txApprovalsSpec: ChainSpec = { + path: ["tx", "approvals"], + network: "optional", wallet: "none", auth: "none", + capability: "tx.multisig.local", + summary: "Show permission, signature approvals, current weight, and expiration", + description: "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", + baseFields: approvalsFields, + baseRefine: hexOrFileRefine, + examples: [{ cmd: "wallet-cli tx approvals --file partially-signed.hex" }], + formatText: TextFormatters.txApprovals, +}; + +export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBinding => ({ + run: async (_ctx, network, input) => service.approvals(network, hexInput(input)), +}); + const signFields = z.object({ - transaction: z.string().min(1) - .describe("unsigned TRON transaction JSON, as built (raw_data, raw_data_hex and txID must agree)"), + transaction: z.string().min(1).optional() + .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), + ...artifactFields, + check: z.boolean().default(false) + .describe("verify signer permission and resulting approval weight online"), + out: z.string().min(1).optional().describe("atomically write co-signed transaction hex to this file"), }); export const txSignSpec: ChainSpec = { @@ -92,28 +153,120 @@ export const txSignSpec: ChainSpec = { network: "optional", wallet: "optional", auth: "required", broadcasts: false, capability: "tx.sign", - summary: "Sign a transaction built elsewhere, without broadcasting", + summary: "Sign transaction JSON or append a signature to transaction hex offline", description: - "Sign a transaction built outside this CLI and output the signed result; broadcast it later\n" + - "with `tx broadcast`. Signs any well-formed transaction without inspecting its contents, but\n" + - "rejects a payload whose txID does not match its raw_data — the signature always covers the\n" + - "exact bytes you passed.", + "With --transaction, preserve the direct JSON signing flow. With --hex/--file, append exactly\n" + + "one signature while preserving prior signatures without requiring a node. Add --check to\n" + + "verify permission membership and approval weight online. This command never broadcasts.", baseFields: signFields, + baseRefine: (input, context) => { + if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { + context.addIssue({ + code: "custom", + path: ["transaction"], + message: "provide exactly one of --transaction, --hex, or --file", + }); + } + if (input.out && input.transaction) { + context.addIssue({ code: "custom", path: ["out"], message: "--out is only valid with --hex or --file" }); + } + if (input.check && input.transaction) { + context.addIssue({ code: "custom", path: ["check"], message: "--check is only valid with --hex or --file" }); + } + }, examples: [ { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'` }, + { cmd: "wallet-cli tx sign --file partially-signed.hex --out signed.hex --password-stdin" }, + { cmd: "wallet-cli tx sign --file partially-signed.hex --check --password-stdin" }, ], - formatText: TextFormatters.txReceipt, + formatText: TextFormatters.txSign, }; -export const txSignTronBinding = (svc: TronTransactionService): FamilyBinding => ({ +export const txSignTronBinding = ( + transactionService: TronTransactionService, + signingService: TronSigService, + multisigService: TronMultisigService, + writer: TransactionArtifactWriter, +): FamilyBinding => ({ run: async (ctx, net, input) => { + exactlyOne([input.transaction, input.hex, input.file], "provide exactly one of --transaction, --hex, or --file"); + if (!input.transaction) { + const hex = hexInput(input); + const result = input.check + ? await multisigService.signChecked(ctx, net, hex) + : await signingService.sign(ctx, net, hex); + if (!input.out) return result; + writer.write(input.out, result.hex); + return { ...result, out: input.out }; + } + if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); + if (input.check) throw new UsageError("invalid_option", "--check is only valid with --hex or --file"); let tx: unknown; try { tx = JSON.parse(input.transaction); } catch { throw new UsageError("invalid_value", "TRON transaction must be JSON"); } - return svc.sign(ctx, net, tx); + return transactionService.sign(ctx, net, tx); + }, +}); + +const tronLinkMultisigFields = z.object({ + create: z.boolean().default(false) + .describe("upload one unsigned transaction and open a TronLink signature collection"), + hex: z.string().min(2).optional().describe("unsigned protocol.Transaction hex used with --create"), + file: z.string().min(1).optional().describe("file containing unsigned transaction hex used with --create"), + sign: z.string().regex(/^(?:0x)?[0-9a-fA-F]{64}$/).optional() + .describe("fetch and co-sign one pending TronLink transaction by txId"), + watch: z.boolean().default(false) + .describe("keep a WebSocket open and report only the count of transactions awaiting this account"), +}); + +export const txTronLinkMultisigSpec: ChainSpec = { + path: ["tx", "multisig"], + network: "optional", wallet: "optional", auth: "conditional", + capability: "tx.multisig.tronlink", + summary: "Coordinate multi-signature collection through the TronLink service", + description: + "With no mode flag, list service-managed transactions for the selected account. --create\n" + + "uploads an UNSIGNED transaction; --sign fetches the accumulated transaction, signs locally,\n" + + "and submits it; --watch opens the official WebSocket count-only notification channel.", + requires: [ + "TronLink service credentials — config tronlinkSecretId / tronlinkSecretKey / tronlinkChannel", + ], + baseFields: tronLinkMultisigFields, + baseRefine: tronLinkMultisigRefine, + examples: [ + { cmd: "wallet-cli tx multisig" }, + { cmd: "wallet-cli tx multisig --create --file tx.unsigned.hex" }, + { cmd: "wallet-cli tx multisig --sign 9c1... --password-stdin" }, + { cmd: "wallet-cli tx multisig --watch" }, + ], + formatText: TextFormatters.txTronLinkMultisig, +}; + +export const txTronLinkMultisigBinding = (service: TronMultisigCollaborationService): FamilyBinding => ({ + run: async (ctx, network, input) => { + const address = ctx.resolveAddress("tron"); + if (input.create) return service.create(network, address, hexInput(input)); + if (input.sign) return service.sign(ctx, network, input.sign); + if (!input.watch) return service.list(network, address); + + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + ctx.streams.event(`Watching TronLink multi-sig service for ${network.id} … (Ctrl-C to stop)`); + try { + return await service.watch(network, address, controller.signal, (count) => { + ctx.streams.event( + `🔔 You have ${count} transaction(s) to sign — view them with: wallet-cli tx multisig`, + ); + }); + } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + } }, }); @@ -161,3 +314,43 @@ function tokenOptional( }); } } + +function hexOrFileRefine(value: { hex?: string; file?: string }, context: z.RefinementCtx): void { + if ([value.hex, value.file].filter((entry) => entry !== undefined).length !== 1) { + context.addIssue({ code: "custom", path: ["hex"], message: "provide exactly one of --hex or --file" }); + } +} + +function hexInput(input: { hex?: string; file?: string }): string { + exactlyOne([input.hex, input.file], "provide exactly one of --hex or --file"); + return input.hex ?? readBoundedTextFile(input.file!, 1024 * 1024 + 4096, "transaction hex file"); +} + +function tronLinkMultisigRefine( + value: z.infer, + context: z.RefinementCtx, +): void { + const modes = [value.create, value.sign !== undefined, value.watch].filter(Boolean).length; + if (modes > 1) { + context.addIssue({ + code: "custom", + path: ["create"], + message: "--create, --sign, and --watch are mutually exclusive", + }); + } + const artifacts = [value.hex, value.file].filter((entry) => entry !== undefined).length; + if (value.create && artifacts !== 1) { + context.addIssue({ + code: "custom", + path: ["hex"], + message: "--create requires exactly one of --hex or --file", + }); + } + if (!value.create && artifacts !== 0) { + context.addIssue({ + code: "custom", + path: ["hex"], + message: "--hex/--file are only valid with --create", + }); + } +} diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index 806d8be26..deb172938 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -11,7 +11,7 @@ const voteForField = z.array(z.string().min(1)).min(1).max(30) export const voteCastSpec: ChainSpec = { path: ["vote", "cast"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "vote.cast", summary: "Cast or replace your full SR vote allocation", diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts new file mode 100644 index 000000000..29089216b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AccountDescriptor } from "../../../../domain/types/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { isChainCommand } from "../contracts/index.js"; +import { registerWalletCommands } from "./wallet.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const descriptor = { + accountId: "wlt_selected", + label: "treasury", + type: "watch", + index: null, + active: false, + addresses: { tron: ADDRESS }, +} satisfies AccountDescriptor; + +function command(options: { + output?: "text" | "json"; + encoded?: string | null; + account?: string; +} = {}) { + const walletService = { + current: vi.fn(() => descriptor), + }; + const qr = { + encode: vi.fn(() => + options.encoded === undefined ? "QR-MATRIX" : options.encoded + ), + }; + const registry = new CommandRegistry(); + registerWalletCommands(registry, { + walletService: walletService as never, + ledger: {} as never, + qr, + }); + const current = registry.resolveNeutral(["current"]); + if (!current || isChainCommand(current)) { + throw new Error("current command missing"); + } + const context = { + activeAccount: options.account ?? "wlt_selected", + output: options.output ?? "text", + warn: vi.fn(), + }; + return { current, context, walletService, qr }; +} + +describe("current --qr", () => { + it("encodes exactly the selected account's TRON address in text mode", async () => { + const fixture = command({ account: "wlt_selected", encoded: "QR" }); + const result = await fixture.current.run( + fixture.context as never, + undefined, + { qr: true }, + ); + + expect(fixture.walletService.current).toHaveBeenCalledWith( + "wlt_selected", + ); + expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); + expect(result).toMatchObject({ + receiveQr: "QR", + receiveAddress: ADDRESS, + }); + }); + + it("keeps JSON data unchanged and never builds terminal art", async () => { + const fixture = command({ output: "json" }); + const result = await fixture.current.run( + fixture.context as never, + undefined, + { qr: true }, + ); + + expect(result).toEqual(descriptor); + expect(fixture.qr.encode).not.toHaveBeenCalled(); + }); + + it("warns and returns the full normal descriptor on a narrow terminal", async () => { + const fixture = command({ encoded: null }); + const result = await fixture.current.run( + fixture.context as never, + undefined, + { qr: true }, + ); + + expect(result).toEqual(descriptor); + expect(fixture.context.warn).toHaveBeenCalledWith( + expect.stringContaining("too narrow"), + ); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 050c3c6b6..c55cd50f7 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -8,6 +8,7 @@ import { Schemas } from "../schemas/index.js" import { CommandRegistry } from "../registry/index.js" import { accountRef, ciEnum } from "../arity/index.js" import type { LedgerDevice } from "../../../../application/ports/ledger-device.js" +import type { QrEncoder } from "../../../../application/ports/qr-encoder.js" import type { WalletService } from "../../../../application/use-cases/wallet-service.js" import { resolveLedgerPath, selectLedgerPath } from "../../../../application/services/ledger-account.js" import { ChainFamily, CHAIN_FAMILIES, FAMILIES } from "../../../../domain/family/index.js" @@ -40,7 +41,14 @@ export const walletImportLedgerInput = walletImportLedgerFields.superRefine((v, if (locators > 1) c.addIssue({ code: "custom", path: ["index"], message: "--index, --path and --address are mutually exclusive" }) }) -export function registerWalletCommands(reg: CommandRegistry, services: { walletService: WalletService; ledger: LedgerDevice }): void { +export function registerWalletCommands( + reg: CommandRegistry, + services: { + walletService: WalletService; + ledger: LedgerDevice; + qr?: QrEncoder; + }, +): void { const wallets = services.walletService const empty = z.object({}) @@ -203,20 +211,49 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS } satisfies CommandDefinition) // ── current ─────────────────────────────────────────────────────────────── - // Read-only: always reports the persisted active account; ignores --account - // (use `use` to change it). wallet:"none" keeps help from - // advertising an --account override here. + const currentFields = z.object({ + qr: z.boolean().default(false) + .describe("render a terminal receive QR containing exactly the selected TRON address; text TTY only"), + }) reg.add({ path: ["current"], network: "none", - wallet: "none", + wallet: "optional", auth: "none", summary: "Show the current active account", - fields: empty, - input: empty, - examples: [{ cmd: "wallet-cli current" }], + description: + "Show the selected account locally. --qr appends a scannable TRON receive-address QR in text mode without unlocking or accessing the network.", + fields: currentFields, + input: currentFields, + examples: [ + { cmd: "wallet-cli current" }, + { cmd: "wallet-cli current --qr" }, + { cmd: "wallet-cli current --qr --account main" }, + ], formatText: TextFormatters.walletCurrent, - run: async () => wallets.current(), + run: async (context, _network, input) => { + const descriptor = wallets.current(context.activeAccount) + if (!input.qr || context.output !== "text") return descriptor + const address = descriptor.addresses.tron + if (!address) { + throw new UsageError( + "invalid_value", + "selected account has no TRON receive address", + ) + } + const qr = services.qr?.encode(address) ?? null + if (!qr) { + context.warn( + "terminal is non-interactive or too narrow for a complete QR code; showing the full address only", + ) + return descriptor + } + return { + ...descriptor, + receiveQr: qr, + receiveAddress: address, + } + }, } satisfies CommandDefinition) // ── rename ──────────────────────────────────────────────────────────────── diff --git a/ts/src/adapters/inbound/cli/context/context.test.ts b/ts/src/adapters/inbound/cli/context/context.test.ts index 7e97044cb..09da96467 100644 --- a/ts/src/adapters/inbound/cli/context/context.test.ts +++ b/ts/src/adapters/inbound/cli/context/context.test.ts @@ -4,12 +4,12 @@ import { StreamManager } from "../stream/index.js"; import { createOutputFormatter } from "../output/index.js"; import type { Globals } from "../contracts/index.js"; -function ctxWith(output: "text" | "json") { +function ctxWith(output: "text" | "json", overrides: Partial = {}) { const out: string[] = []; const err: string[] = []; const sm = new StreamManager(output, false, (s) => out.push(s), (s) => err.push(s)); const formatter = createOutputFormatter(output, sm, 0); - const globals = { output, verbose: false } as Globals; + const globals = { output, verbose: false, ...overrides } as Globals; // only streams + formatter are exercised by emit(); the rest is lazily used elsewhere. const deps = { config: { timeoutMs: 1 }, streams: sm, formatter } as unknown as RuntimeDeps; return { ctx: buildExecutionContext(globals, deps), out, err }; @@ -29,3 +29,12 @@ describe("ExecutionContext.emit (progress events)", () => { expect(err[0]).toContain("broadcasting"); }); }); + +describe("ExecutionContext direct address target", () => { + it("resolves a valid --account address without requiring a local wallet record", () => { + const address = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; + const { ctx } = ctxWith("json", { account: address }); + expect(ctx.activeAccount).toBe(address); + expect(ctx.resolveAddress("tron")).toBe(address); + }); +}); diff --git a/ts/src/adapters/inbound/cli/context/index.ts b/ts/src/adapters/inbound/cli/context/index.ts index bb49993a7..df9c77b9a 100644 --- a/ts/src/adapters/inbound/cli/context/index.ts +++ b/ts/src/adapters/inbound/cli/context/index.ts @@ -3,7 +3,7 @@ * account-level: activeAccount is resolved lazily from --account/--wallet or wallets.json. * Build is side-effect-free; secrets never enter the serializable surface. */ -import type { AccountRef, ChainFamily, Config, OutputMode } from "../../../../domain/types/index.js"; +import type { AccountRef, ChainFamily, Config, OutputMode, WarningView } from "../../../../domain/types/index.js"; import type { ProgressEvent } from "../../../../application/contracts/index.js"; import type { NetworkRegistry } from "../../../../application/ports/network-registry.js"; import type { ExecutionContext, Globals, SecretResolver, StreamManager } from "../contracts/index.js"; @@ -13,6 +13,7 @@ import type { AccountStore } from "../../../../application/ports/account-store.j import { accountRef, walletAddress } from "../../../../domain/wallet/index.js"; import { WalletError } from "../../../../domain/errors/index.js"; import { SOURCE_KINDS } from "../../../../domain/sources/index.js"; +import { addressCodec, familyOf } from "../../../../domain/family/index.js"; export interface RuntimeDeps { config: Config; @@ -65,6 +66,7 @@ class ExecutionContextImpl implements ExecutionContext { const ks = this.deps.keystore; let ref: AccountRef | null; if (this.globals.account) { + if (familyOf(this.globals.account)) return this.globals.account as AccountRef; const { wallet, index } = ks.resolveAccount(this.globals.account); ref = accountRef(wallet.id, SOURCE_KINDS[wallet.source.type].isHD ? index : null); } else { @@ -78,6 +80,9 @@ class ExecutionContextImpl implements ExecutionContext { } resolveAddress(family: ChainFamily): string { + if (this.globals.account && addressCodec(family).validate(this.globals.account)) { + return this.globals.account; + } const { wallet, index } = this.deps.keystore.resolveAccount(this.activeAccount); const address = walletAddress(wallet, family, index); if (!address) throw new WalletError("missing_wallet_address", `active account has no ${family} address`); @@ -88,7 +93,7 @@ class ExecutionContextImpl implements ExecutionContext { this.deps.streams.event(this.deps.formatter.event(e)); } - warn(message: string): void { + warn(message: string | WarningView): void { this.deps.streams.diagnostic("warn", message); } } diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 482d5f8fc..604a0ebfc 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -13,9 +13,10 @@ export interface Example { // "optional" = the command operates on an account; --account is optional and falls back to the // active account (errors only if no account exists at all). "none" = never touches an account. // (No "required": no command forces --account — active is always a valid default. cf. network.) -// "required" = unlocks the master password (sign / read secrets / encrypt); -// "none" = never unlocks. (No middle state — a command either needs the password or it doesn't.) -export type AuthRequirement = "none" | "required"; +// "required" = every execution needs the master password (sign / read secrets / encrypt); +// "conditional" = only selected execution modes need it; other modes run without a password; +// "none" = never needs it. +export type AuthRequirement = "none" | "conditional" | "required"; /** secret/payload channel a command reads from stdin; documents the matching --*-stdin flag. * (Wallet-secret entry — mnemonic/private-key/master-password — is TTY-only, so those never diff --git a/ts/src/adapters/inbound/cli/contracts/envelope.ts b/ts/src/adapters/inbound/cli/contracts/envelope.ts index 99cef10ae..b2280a14c 100644 --- a/ts/src/adapters/inbound/cli/contracts/envelope.ts +++ b/ts/src/adapters/inbound/cli/contracts/envelope.ts @@ -4,6 +4,9 @@ */ import type { ChainFamily } from "../../../../domain/family/index.js"; import type { OutputMode } from "../../../../domain/types/primitives.js"; +import type { WarningView } from "../../../../domain/types/permission.js"; + +export type WarningItem = string | WarningView; export interface ChainView { family: ChainFamily; @@ -12,7 +15,7 @@ export interface ChainView { } export interface Meta { durationMs: number; - warnings: string[]; + warnings: WarningItem[]; } export interface ResultEnvelope { schema: "wallet-cli.result.v1"; diff --git a/ts/src/adapters/inbound/cli/contracts/runtime.ts b/ts/src/adapters/inbound/cli/contracts/runtime.ts index 06c6788de..659c23e58 100644 --- a/ts/src/adapters/inbound/cli/contracts/runtime.ts +++ b/ts/src/adapters/inbound/cli/contracts/runtime.ts @@ -1,18 +1,19 @@ /** CLI runtime seams implemented by stream and secret input adapters. */ import type { NetworkDescriptor } from "../../../../domain/types/network.js"; +import type { WarningItem } from "./envelope.js"; export type DiagnosticLevel = "info" | "debug" | "warn"; export interface StreamManager { result(text: string): void; - diagnostic(level: DiagnosticLevel, msg: string): void; + diagnostic(level: DiagnosticLevel, msg: WarningItem): void; /** always-on stderr line. */ errorLine(msg: string): void; /** intermediate progress frame → stderr plain line; null is skipped (StreamManager). */ event(frame: string | null): void; readStdinOnce(): string; /** warnings accumulated for the JSON envelope's meta.warnings. */ - warnings(): string[]; + warnings(): WarningItem[]; } export type SecretKind = "password" | "privateKey" | "mnemonic" | "tx" | "message"; diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index 6d6d80b0d..1d1e41a20 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -109,4 +109,13 @@ describe("Requires: master password line", () => { expect(renderAuthLine({ interactive: true })).toContain("enter it interactively in a TTY") expect(renderAuthLine({ interactive: true })).not.toContain("never prompts") }) + + // "locked" is taken: stake help uses it for the TRX freeze period, and Ledger help uses + // "unlocked" for the device. Say what the reader must supply, not what state the keystore is in. + it("describes mode-dependent authentication in terms of the password, not a lock state", () => { + const line = renderAuthLine({ auth: "conditional" }) + expect(line).toContain("only when the selected mode signs") + expect(line).toContain("other modes need no password") + expect(line).not.toContain("locked") + }) }) diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 067d21c9a..b9e8e9128 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -98,9 +98,11 @@ export class HelpService { ["list", "List wallets / accounts", ""], ] as const const management = [ - ["account", "Query on-chain account state", ""], + ["account", "Query on-chain account state, activate & name accounts", ""], + ["permission", "View and update account multi-sign permissions", "tron"], ["token", "Manage the token address book and query tokens", ""], - ["tx", "Build, send, broadcast, and inspect transactions", ""], + ["tx", "Build, send, broadcast, co-sign, and inspect transactions", ""], + ["gasfree", "Gas-free token transfers via the GasFree service", "tron"], ["contract", "Call, send, deploy, and inspect smart contracts", ""], ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], @@ -112,7 +114,7 @@ export class HelpService { ] as const const commands = [ ["use", "Set the active account", ""], - ["current", "Show the current (active) account", ""], + ["current", "Show the current account (--qr for a receive QR code)", ""], ["rename", "Rename an account label", ""], ["derive", "Derive the next HD account from a seed wallet", ""], ["backup", "Export an account's secret + metadata (0600)", ""], @@ -120,6 +122,9 @@ export class HelpService { ["config", "Show / get / set configuration values", ""], ["networks", "List known networks", ""], ["change-password", "Change the master password (re-encrypt keystores)", ""], + ["encoding", "Convert / validate addresses & encodings across formats", ""], + ["address", "Generate a random keypair (local, not stored)", ""], + ["contact", "Manage the recipient address book", ""], ] as const const sections = [common, management, commands] as const const nameWidth = Math.max(...sections.flat().map(([name]) => name.length)) + 2 @@ -271,13 +276,19 @@ export class HelpService { // A command only prompts when it opts in (`interactive`); everything else fails fast so // scripts and agents get a deterministic error instead of a hung prompt. Say which one this // is — promising a TTY prompt that never comes sends the reader hunting for a broken terminal. - if (c.auth === "required") requires.push( - c.secretsTtyOnly - ? "the master password — entered interactively in a TTY" - : c.interactive - ? "master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY" - : "master password — pass --password-stdin; this command never prompts", - ) + if (c.auth === "required") { + requires.push( + c.secretsTtyOnly + ? "the master password — entered interactively in a TTY" + : c.interactive + ? "master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY" + : "master password — pass --password-stdin; this command never prompts", + ) + } else if (c.auth === "conditional") { + requires.push( + "the master password only when the selected mode signs — pass --password-stdin then; other modes need no password", + ) + } if (c.wallet !== "none") requires.push("an account — defaults to active; override with --account (or run `wallet-cli use ` to change the active account)") if (requires.length) { lines.push("", "Requires:") @@ -399,7 +410,7 @@ function formatDefault(v: unknown): string { } // Per-command "Global options" projection: output/timeout/verbose always; --network only when the -// command selects a network; --password-stdin only when it requires unlock; --wait/--wait-timeout +// command selects a network; --password-stdin when it may unlock; --wait/--wait-timeout // only for ✍️ broadcast commands; --account only when the command acts as an account (also surfaced, // with fuller semantics, under Requires). The full GLOBAL_FLAGS array still backs the --json-schema catalog. function globalFlagsForText( @@ -412,7 +423,7 @@ function globalFlagsForText( return GLOBAL_FLAGS.filter((g) => { if (g.flag === "--account") return wallet !== "none" if (g.flag === "--network") return network !== "none" - if (g.flag === "--password-stdin") return auth === "required" && !secretsTtyOnly + if (g.flag === "--password-stdin") return auth !== "none" && !secretsTtyOnly if (g.flag === "--wait" || g.flag === "--wait-timeout") return broadcasts return true }) @@ -429,17 +440,22 @@ function globalFlagLine(g: GlobalFlag): string { // ` --help` page need an entry; absent → the description line is omitted. const GROUP_DESCRIPTIONS: Record = { import: "Import a wallet from an existing secret or device.", - account: "Query on-chain account state.", + account: "Query on-chain account state, activate accounts, and set on-chain identity fields.", token: "Manage the token address book and query tokens.", - tx: "Build, send, broadcast, and inspect transactions.", + tx: "Build, send, broadcast, co-sign, and inspect transactions.", + gasfree: "Gas-free token transfers via the GasFree service (open.gasfree.io).\nRequires API credentials (config gasfreeApiKey / gasfreeApiSecret).", contract: "Call, send, deploy, and inspect smart contracts.", stake: "Stake / delegate resources & query state (TRON Stake 2.0).", vote: "Vote for super representatives (SR).\nVoting accrues rewards — query and claim them with 'wallet-cli reward'.", reward: "Query and withdraw voting/block rewards.", + permission: "View and update account permissions (TRON multi-sign).\nMisconfiguring owner permission can permanently lock the account.", chain: "Query on-chain parameters, resource prices, and node status.", message: "Sign arbitrary messages.", "typed-data": "Sign EIP-712 / TIP-712 structured data.", block: "Get a block (latest if omitted).", + encoding: "Convert and validate addresses and encodings across formats.", + address: "Generate a random secp256k1 keypair locally without storing it in the wallet.", + contact: "Manage the local recipient address book.\nNames can be used directly in 'tx send --to' and 'gasfree transfer --to'.", } /** "--output, -o " style header for text help. */ diff --git a/ts/src/adapters/inbound/cli/input/prompt/index.ts b/ts/src/adapters/inbound/cli/input/prompt/index.ts index 6b0a02975..2dd8c9298 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/index.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/index.ts @@ -122,15 +122,32 @@ function dim(s: string): string { return process.env.NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`; } -/** Real backend: reads /dev/tty, writes prompts to /dev/tty (never stdout). */ +export interface TtyBackendOptions { + platform?: NodeJS.Platform; + stdin?: ReadStream; +} + +/** Real backend: POSIX reads /dev/tty; Windows reads its TTY stdin. Prompts never use stdout. */ export class TtyBackend implements PromptBackend { #tty: boolean; + readonly #platform: NodeJS.Platform; + readonly #stdioInput: ReadStream; + #ownsInput = false; #fd?: number; #input?: ReadStream; #keyQueue: KeyEvent[] = []; #pendingKey?: (key: KeyEvent) => void; #keyListener?: (s: string, key: KeyEvent) => void; - constructor() { + constructor(opts: TtyBackendOptions = {}) { + this.#platform = opts.platform ?? process.platform; + this.#stdioInput = opts.stdin ?? process.stdin; + if (this.#platform === "win32") { + // Windows has no /dev/tty. Console/ConPTY hosts (cmd, PowerShell, Windows Terminal) expose a + // TTY directly; Git Bash/MSYS2 do so through ConPTY or winpty. Capability detection keeps this + // shell-agnostic. Require setRawMode because arrow-select and hidden input depend on it. + this.#tty = this.#stdioInput.isTTY === true && typeof this.#stdioInput.setRawMode === "function"; + return; + } // Probe for a controlling terminal without holding the fd; the real stream opens on first prompt. try { closeSync(openSync("/dev/tty", "r")); @@ -151,8 +168,14 @@ export class TtyBackend implements PromptBackend { */ #stream(): ReadStream { if (!this.#input) { - this.#fd = openSync("/dev/tty", "r"); - this.#input = new ReadStream(this.#fd); + if (this.#platform === "win32") { + if (!this.#tty) throw new ExecutionError("tty_required", "interactive input requires a Windows console TTY"); + this.#input = this.#stdioInput; + } else { + this.#fd = openSync("/dev/tty", "r"); + this.#input = new ReadStream(this.#fd); + this.#ownsInput = true; + } } return this.#input; } @@ -233,13 +256,15 @@ export class TtyBackend implements PromptBackend { this.#pendingKey = resolve; }); } - /** Release the persistent /dev/tty stream so the event loop drains and the process exits. */ + /** Release an owned /dev/tty stream; process.stdin on Windows must never be destroyed. */ close(): void { if (this.#input) { - try { this.#input.setRawMode(false); } catch { /* may already be closed */ } - this.#input.destroy(); + try { this.endRaw(); } catch { /* may already be closed */ } + if (this.#ownsInput) this.#input.destroy(); + else this.#input.pause(); this.#input = undefined; } + this.#ownsInput = false; this.#fd = undefined; } } diff --git a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts index 197da86d6..04ae7d715 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect } from "vitest"; -import { Prompter, type PromptBackend, type KeyEvent } from "./index.js"; +import { PassThrough } from "node:stream"; +import type { ReadStream } from "node:tty"; +import { describe, it, expect, vi } from "vitest"; +import { Prompter, TtyBackend, type PromptBackend, type KeyEvent } from "./index.js"; class FakeBackend implements PromptBackend { out = ""; @@ -88,3 +90,34 @@ describe("Prompter.select", () => { expect(v).toBe("x1"); }); }); + +describe("TtyBackend on Windows", () => { + it.each([ + "Command Prompt", + "Windows PowerShell 5.1", + "PowerShell 7", + "Windows Terminal with a Console/ConPTY profile", + "Git Bash or MSYS2 through ConPTY/winpty", + ])("supports %s when the host exposes raw-capable stdin", () => { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode: (mode: boolean) => void }; + input.isTTY = true; + input.setRawMode = vi.fn(); + const backend = new TtyBackend({ platform: "win32", stdin: input as unknown as ReadStream }); + + expect(backend.isTTY()).toBe(true); + backend.beginRaw(); + backend.close(); + + expect(input.setRawMode).toHaveBeenCalledWith(true); + expect(input.setRawMode).toHaveBeenLastCalledWith(false); + expect(input.destroyed).toBe(false); + }); + + it("rejects redirected stdin instead of treating a script pipe as an interactive shell", () => { + const input = new PassThrough() as PassThrough & { isTTY: boolean }; + input.isTTY = false; + const backend = new TtyBackend({ platform: "win32", stdin: input as unknown as ReadStream }); + + expect(backend.isTTY()).toBe(false); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/envelope.ts b/ts/src/adapters/inbound/cli/output/envelope.ts index 14fce6023..e5419f27f 100644 --- a/ts/src/adapters/inbound/cli/output/envelope.ts +++ b/ts/src/adapters/inbound/cli/output/envelope.ts @@ -4,7 +4,7 @@ * Pure (no I/O); the formatter turns the envelope into strings. */ import type { NetworkDescriptor } from "../../../../domain/types/index.js"; -import type { ChainView, ErrorEnvelope, Meta, ResultEnvelope } from "../contracts/index.js"; +import type { ChainView, ErrorEnvelope, Meta, ResultEnvelope, WarningItem } from "../contracts/index.js"; type CliErrorEnvelopeShape = { code: string; message: string; details?: object }; @@ -27,7 +27,7 @@ function chainView(net: NetworkDescriptor): ChainView { }; } -function meta(durationMs: number, warnings: string[]): Meta { +function meta(durationMs: number, warnings: WarningItem[]): Meta { return { durationMs, warnings }; } @@ -36,7 +36,7 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, data: unknown, - m: { durationMs: number; warnings: string[] }, + m: { durationMs: number; warnings: WarningItem[] }, ): ResultEnvelope { const env: ResultEnvelope = { schema: SCHEMA_VERSION, @@ -53,7 +53,7 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, err: CliErrorEnvelopeShape, - m: { durationMs: number; warnings: string[] }, + m: { durationMs: number; warnings: WarningItem[] }, ): ErrorEnvelope { const env: ErrorEnvelope = { schema: SCHEMA_VERSION, diff --git a/ts/src/adapters/inbound/cli/render/contact.ts b/ts/src/adapters/inbound/cli/render/contact.ts new file mode 100644 index 000000000..d3c8efc7b --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/contact.ts @@ -0,0 +1,33 @@ +import type { + ContactListView, + ContactView, +} from "../../../../domain/types/index.js"; +import { ok, receipt, table } from "./layout.js"; + +export const ContactFormatters = { + contactAdd: (value: ContactView): string => receipt( + ok(), + "Contact added", + [ + ["Name", value.name], + ["Address", value.address], + ["Note", value.note ?? "—"], + ], + ), + contactRemove: (value: ContactView): string => receipt( + ok(), + "Contact removed", + [ + ["Name", value.name], + ["Address", value.address], + ], + ), + contactList: (value: ContactListView): string => table( + ["Name", "Address", "Note"], + value.contacts.map((entry) => [ + entry.name, + entry.address, + entry.note ?? "—", + ]), + ), +}; diff --git a/ts/src/adapters/inbound/cli/render/encoding.ts b/ts/src/adapters/inbound/cli/render/encoding.ts new file mode 100644 index 000000000..d3fe5268f --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/encoding.ts @@ -0,0 +1,41 @@ +import type { EncodingConversion } from "../../../../domain/encoding/index.js"; +import { ok, query, receipt } from "./layout.js"; + +export const EncodingFormatters = { + encodingConvert: (value: EncodingConversion): string => + "tron" in value + ? query([ + ["TRON", value.tron], + ["TRON hex", value.tronHex], + ["EVM", value.evm], + ]) + : query([ + ["Hex", value.hex], + ["Base64", value.base64], + ["Base58Check", value.base58check], + ]), + addressGenerate: (value: { + tron: string; + evm: string; + secretFile?: string; + privateKey?: string; + }): string => { + const rendered = receipt( + ok(), + "Keypair generated (NOT stored in the wallet)", + [ + ["TRON address", value.tron], + ["EVM address", value.evm], + [ + "Private key", + value.privateKey ?? `written to ${value.secretFile}`, + ], + ], + ); + return `${ + value.privateKey + ? "! Private key printed to stdout — keep it offline\n" + : "" + }${rendered}\n\n! To sign with this key, import it: wallet-cli import private-key`; + }, +}; diff --git a/ts/src/adapters/inbound/cli/render/gasfree.ts b/ts/src/adapters/inbound/cli/render/gasfree.ts new file mode 100644 index 000000000..7e45ac81e --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/gasfree.ts @@ -0,0 +1,99 @@ +import type { + GasFreeInfoView, + GasFreeTraceView, + GasFreeTransferView, +} from "../../../../domain/types/index.js"; +import { fromBaseUnits } from "../../../../domain/amounts/index.js"; +import { + fail, + ok, + pending, + query, + receipt, + table, +} from "./layout.js"; + +function amount(value: string, decimals: number, symbol: string): string { + return `${fromBaseUnits(value, decimals)} ${symbol}`; +} + +export const GasFreeFormatters = { + gasFreeInfo: (value: GasFreeInfoView): string => [ + query([ + ["Owner", value.ownerAddress], + ["GasFree address", value.gasFreeAddress], + ["Status", value.active ? "active" : "not activated"], + ["Nonce", value.nonce], + ]), + table( + ["Token", "Balance", "Activation fee", "Transfer fee"], + value.tokens.map((token) => [ + token.symbol, + amount(token.balance, token.decimals, token.symbol), + amount(token.activateFee, token.decimals, token.symbol), + amount(token.transferFee, token.decimals, token.symbol), + ]), + ), + ].join("\n\n"), + + gasFreeTransfer: (value: GasFreeTransferView): string => { + const marker = + value.stage === "confirmed" + ? ok() + : value.stage === "failed" + ? fail() + : pending(); + const title = + value.stage === "dry-run" + ? `Dry run — GasFree transfer ${amount(value.amount, value.decimals, value.token)} (not submitted)` + : value.stage === "confirmed" + ? `Sent ${amount(value.amount, value.decimals, value.token)} via GasFree` + : value.stage === "failed" + ? "GasFree transfer failed" + : `Submitted to GasFree — send ${amount(value.amount, value.decimals, value.token)}`; + const result = receipt(marker, title, [ + ["Trace ID", value.traceId ?? ""], + ["TxID", value.txId ?? ""], + ["From", value.from], + ["To", value.toContact ? `${value.toContact} (${value.to})` : value.to], + [ + "Service fee", + amount(value.serviceFee, value.decimals, value.token), + ], + [ + "Activation fee", + amount(value.activateFee, value.decimals, value.token), + ], + [ + "Authorized max fee", + amount(value.authorizedMaxFee, value.decimals, value.token), + ], + ["Total", amount(value.totalDeducted, value.decimals, value.token)], + [ + "Status", + value.state?.toLowerCase() + ?? (value.stage === "dry-run" ? "not submitted" : "accepted"), + ], + ["Reason", value.failureReason ?? ""], + ]); + return value.stage === "submitted" && value.traceId + ? `${result}\n! Track it: wallet-cli gasfree trace ${value.traceId}` + : result; + }, + + gasFreeTrace: (value: GasFreeTraceView): string => query([ + ["Trace ID", value.traceId], + ["Status", value.state.toLowerCase()], + ["TxID", value.txId ?? ""], + ["Token", value.token], + ["Amount", amount(value.amount, value.decimals, value.token)], + ["Service fee", amount(value.serviceFee, value.decimals, value.token)], + [ + "Activation fee", + amount(value.activateFee, value.decimals, value.token), + ], + ["Total", amount(value.totalDeducted, value.decimals, value.token)], + ["To", value.to], + ["Reason", value.failureReason ?? ""], + ]), +}; diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 041647baa..cc5dc0028 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -22,6 +22,11 @@ import { VoteFormatters } from "./vote.js" import { RewardFormatters } from "./reward.js" import { ChainFormatters } from "./chain.js" import { MiscFormatters } from "./misc.js" +import { PermissionFormatters } from "./permission.js" +import { MultisigFormatters } from "./multisig.js" +import { GasFreeFormatters } from "./gasfree.js" +import { ContactFormatters } from "./contact.js" +import { EncodingFormatters } from "./encoding.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -34,6 +39,11 @@ export const TextFormatters = { ...RewardFormatters, ...ChainFormatters, ...MiscFormatters, + ...PermissionFormatters, + ...MultisigFormatters, + ...GasFreeFormatters, + ...ContactFormatters, + ...EncodingFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/inbound/cli/render/multisig.ts b/ts/src/adapters/inbound/cli/render/multisig.ts new file mode 100644 index 000000000..680c55749 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/multisig.ts @@ -0,0 +1,141 @@ +import type { + TronLinkMultisigView, + TxApprovalView, + TxSignTransactionView, + TxSignView, +} from "../../../../domain/types/index.js"; +import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; +import { formatAtWithRelative, formatInt, formatSun } from "./scalars.js"; +import { ok, query, receipt, table } from "./layout.js"; +import { TxFormatters } from "./tx.js"; + +function transactionType(value: TxApprovalView): string { + const label = value.operation ? `${value.operation} (${value.contractType})` : value.contractType; + if (!value.rawAmount) return label; + return value.contractType === "TransferContract" + ? `${label} — ${formatSun(value.rawAmount)} TRX` + : `${label} — ${value.rawAmount} base units`; +} + +export function renderApproval(value: TxApprovalView): string { + const permissionKind = value.permission.id === 0 ? "owner" : value.permission.id === 1 ? "witness" : "active"; + const expires = `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`; + const transaction = query([ + ["TxID", value.txId], + ["Type", transactionType(value)], + ["From", value.from ?? ""], + ["To", value.to ?? ""], + ["Permission", `${permissionKind} "${value.permission.name}" (id ${value.permission.id}) threshold ${formatInt(value.permission.threshold)}`], + ["Expires", expires], + ]); + const progress = value.thresholdReached + ? `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — threshold reached` + : `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — ${formatInt(value.missingWeight)} more weight needed`; + const approved = value.approved.length === 0 + ? "No approved signers." + : table( + ["Approved signer", "Weight"], + value.approved.map((signer) => [signer.address, formatInt(signer.weight)]), + ); + const expired = value.expired ? "\n! Transaction expired; build a new transaction before collecting signatures." : ""; + return `Transaction\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}\n\n${progress}\n${approved}${expired}`; +} + +function renderTronLink(value: TronLinkMultisigView): string { + if ("transactions" in value) { + const heading = `Multi-sig transactions — TronLink service (${value.total} total)`; + if (value.transactions.length === 0) return `${heading}\nNo transactions found.`; + const rows = value.transactions.map((transaction) => { + const amount = transaction.rawAmount + ? transaction.contractType === "TransferContract" + ? `${formatSun(transaction.rawAmount)} TRX` + : `${transaction.rawAmount} base units` + : ""; + const state = transaction.awaitingMySignature ? "awaiting you" : transaction.state; + return [ + transaction.txId, + transaction.contractType, + amount, + state, + `${formatInt(transaction.currentWeight)} / ${formatInt(transaction.permission.threshold)}`, + formatAtWithRelative(transaction.expiration), + ]; + }); + const hint = value.transactions.some((transaction) => transaction.awaitingMySignature) + ? "\n! Co-sign one with: wallet-cli tx multisig --sign " + : ""; + return `${heading}\n${table( + ["TxID", "Type", "Amount", "State", "Progress", "Expires"], + rows, + )}${hint}`; + } + if (value.action === "watch") { + return receipt(ok(), "Stopped watching TronLink multi-sig service", [ + ["Address", value.address], + ["Notifications", formatInt(value.notifications)], + ]); + } + if (value.action === "create") { + return `${receipt(ok(), "Created on TronLink multi-sig service", [ + ["TxID", value.transaction.txId], + ])}\n\n${renderApproval(value.transaction)}\n! Each signer signs it with: wallet-cli tx multisig --sign ${value.transaction.txId}`; + } + const signed = receipt(ok(), "Signed & submitted", [ + ["Signer", `${value.signer} (weight ${formatInt(value.signerWeight)})`], + ["Hex", value.hex], + ]); + const broadcast = value.transaction.thresholdReached + ? `\n! Broadcast it: wallet-cli tx broadcast --hex ${value.hex}` + : ""; + return `${signed}\n\n${renderApproval(value.transaction)}${broadcast}`; +} + +function renderSign(value: TxSignView): string { + const artifact = value.out ? `written to ${value.out}` : value.hex; + const signer = value.checked + ? `${value.signer} (weight ${formatInt(value.signerWeight)})` + : value.signer; + const action = receipt(ok(), "Signature added", [ + ["Signer", signer], + ["Hex", artifact], + ]); + if (!value.checked || !value.approval) { + return `${action}\n\n${renderSignedTransaction(value.transaction)}\n` + + "! Approval state was not checked online. Inspect it with: " + + `wallet-cli tx approvals ${value.out ? `--file ${value.out}` : "--hex "}`; + } + const next = value.approval.thresholdReached + ? `\n! Broadcast it: wallet-cli tx broadcast ${value.out ? `--file ${value.out}` : "--hex "}` + : ""; + return `${action}\n\n${renderApproval(value.approval)}${next}`; +} + +function renderSignedTransaction(value: TxSignTransactionView): string { + const permissionKind = value.permissionId === 0 ? "owner" : value.permissionId === 1 ? "witness" : "active"; + const label = value.operation ? `${value.operation} (${value.contractType})` : value.contractType; + const type = !value.rawAmount + ? label + : value.contractType === "TransferContract" + ? `${label} — ${formatSun(value.rawAmount)} TRX` + : `${label} — ${value.rawAmount} base units`; + const transaction = query([ + ["TxID", value.txId], + ["Type", type], + ["From", value.from ?? ""], + ["To", value.to ?? ""], + ["Permission", `${permissionKind} (id ${value.permissionId})`], + ["Signatures", formatInt(value.signatures)], + ["Expires", `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`], + ]); + return `Transaction (local inspection)\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}`; +} + +export const MultisigFormatters = { + txApprovals: ((value) => renderApproval(value)) satisfies TextFormatter, + txSign: ((value: TxSignView | any, ctx?: TextRenderContext) => + value.kind === "tx-sign" + ? renderSign(value) + : TxFormatters.txReceipt(value, ctx)) satisfies TextFormatter, + txTronLinkMultisig: ((value: TronLinkMultisigView) => + renderTronLink(value)) satisfies TextFormatter, +}; diff --git a/ts/src/adapters/inbound/cli/render/permission.ts b/ts/src/adapters/inbound/cli/render/permission.ts new file mode 100644 index 000000000..0105892de --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/permission.ts @@ -0,0 +1,81 @@ +import type { AccountPermissionsView, PermissionGroupView } from "../../../../domain/types/index.js"; +import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; +import { formatInt, formatSun } from "./scalars.js"; +import { fail, ok, pending, receipt, type Pair } from "./layout.js"; + +function operationLines(labels: string[]): string[] { + const lines: string[] = []; + let current = ""; + for (const label of labels) { + const next = current ? `${current} · ${label}` : label; + if (next.length > 76 && current) { + lines.push(current); + current = label; + } else { + current = next; + } + } + if (current) lines.push(current); + return lines; +} + +function permissionCard(permission: PermissionGroupView, active = false): string { + const activePermission = active ? permission as AccountPermissionsView["actives"][number] : undefined; + const lines = [ + `Permission Name ${permission.name} (id ${permission.id}${active ? ", active" : ""})`, + ]; + if (activePermission) { + const operations = [ + ...activePermission.operationLabels, + ...activePermission.unknownOperationIds.map((id) => `Unknown contract type ${id}`), + ]; + const wrapped = operationLines(operations); + lines.push(`Operation(s) ${wrapped[0] ?? ""}`); + for (const continuation of wrapped.slice(1)) lines.push(` ${continuation}`); + lines[lines.length - 1] += ` (${operations.length} total)`; + lines.push(`Operations Hex ${activePermission.operationsHex}`); + } + lines.push(`Threshold ${formatInt(permission.threshold)}`); + lines.push("Authorized To Address Weight"); + for (const key of permission.keys) { + lines.push(` ${key.address} ${String(key.weight).padStart(6)}${key.local ? ` (this wallet: ${key.local})` : ""}`); + } + return lines.join("\n"); +} + +function renderPermissions(value: AccountPermissionsView, ctx?: TextRenderContext): string { + const account = ctx?.accountLabel ? `${ctx.accountLabel} (${value.address})` : value.address; + return [ + `Account ${account}`, + "", + permissionCard(value.owner), + ...(value.witness ? ["", permissionCard(value.witness)] : []), + ...value.actives.flatMap((permission) => ["", permissionCard(permission, true)]), + ].join("\n"); +} + +function updateReceipt(value: any): string { + if ((value.mode === "build-only" || value.mode === "sign-only") && value.hex) return String(value.hex); + const pairs: Pair[] = []; + if (value.txId) pairs.push(["TxID", String(value.txId)]); + if (value.blockNumber !== undefined) pairs.push(["Block", `#${formatInt(value.blockNumber)}`]); + const feeSun = value.feeSun ?? value.fee?.feeSun; + if (feeSun !== undefined) pairs.push(["Fee", `${formatSun(feeSun)} TRX`]); + if (value.mode === "dry-run") pairs.push(["Status", "not submitted"]); + else if (value.stage === "submitted") pairs.push(["Status", "pending — not yet on-chain"]); + else if (value.stage === "failed") pairs.push(["Status", String(value.result ?? "failed")]); + else if (value.stage === "confirmed") pairs.push(["Status", "success"]); + const header = value.mode === "dry-run" + ? receipt(pending(), "Permission update dry run", pairs) + : value.stage === "failed" + ? receipt(fail(), "Permission update failed", pairs) + : value.stage === "confirmed" + ? receipt(ok(), "Permissions updated", pairs) + : receipt(pending(), "Permission update submitted", pairs); + return value.permissions ? `${header}\n\n${renderPermissions(value.permissions)}` : header; +} + +export const PermissionFormatters = { + permissionShow: ((value, ctx) => renderPermissions(value, ctx)) satisfies TextFormatter, + permissionUpdate: ((value) => updateReceipt(value)) satisfies TextFormatter, +}; diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 54fb0ebc4..f8d478745 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -34,11 +34,21 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { const family = renderFamily(ctx) if (r.mode === "dry-run") { return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ + ...receiptRows(r), + ["Fee", r.multiSignFeeSun === undefined + ? formatFee(r.fee, family) + : `${formatSun(r.multiSignFeeSun)} TRX multi-sign fee`], + ["Tx", summarizeTx(r.tx)], + ]) + } + if (r.mode === "build-only") { + return r.hex ?? receipt(pending(), `Built ${actionLabel(r.kind)}`, [ ["Fee", formatFee(r.fee, family)], ["Tx", summarizeTx(r.tx)], ]) } if (r.mode === "sign-only") { + if (r.hex) return r.hex // kv() drops empty rows, so a fee-less signature (tx sign estimates nothing) omits the Fee line. return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ ["Address", r.address ?? ""], @@ -135,18 +145,35 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { // switch total over TxReceiptKind. case "sign": return "Signed" + case "permission-update": + return "Permissions updated" + case "account-activate": + return "Account activated" + case "account-set": + return `On-chain ${r.field ?? "account field"} set` } } /** action-specific extra rows (To/From/Address/Contract), by kind. */ function receiptRows(r: TxReceiptView): Pair[] { const rows: Pair[] = [] + if (r.multiSignFeeSun) rows.push(["Multi-sign fee", `${formatSun(r.multiSignFeeSun)} TRX`]) if (r.kind === "stake-delegate") rows.push(["To", String(r.receiver ?? "")]) else if (r.kind === "stake-undelegate") rows.push(["From", String(r.receiver ?? "")]) else if (r.kind === "contract-deploy") rows.push(["Address", String(r.contractAddress ?? "")]) else if (r.kind === "vote-cast" && Array.isArray(r.votes)) rows.push(["Votes", r.votes.map((vote) => `${vote.witness}=${formatInt(vote.count)}`).join(", ")]) else if (r.kind === "reward-withdraw") rows.push(["Amount", `${formatSun(r.rewardSun ?? r.withdrawnSun ?? 0)} TRX`]) - else if (r.to ?? r.receiver) rows.push(["To", String(r.to ?? r.receiver)]) + else if (r.kind === "account-activate") { + rows.push(["Address", String(r.address ?? "")]) + rows.push(["Payer", String(r.payer ?? "")]) + } else if (r.kind === "account-set") { + rows.push(["Address", String(r.address ?? "")]) + rows.push([r.field === "id" ? "ID" : "Name", String(r.value ?? "")]) + } + else if (r.to ?? r.receiver) { + const address = String(r.to ?? r.receiver) + rows.push(["To", r.toContact ? `${r.toContact} (${address})` : address]) + } if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]) return rows } @@ -198,6 +225,12 @@ function actionLabel(kind: TxReceiptKind): string { return "vote cast" case "reward-withdraw": return "reward withdraw" + case "permission-update": + return "permission update" + case "account-activate": + return "account activate" + case "account-set": + return "account set" } } diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index 6677c5fe3..b8a67c16f 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -24,7 +24,13 @@ export const WalletFormatters = { }) satisfies TextFormatter, walletCurrent: ((data) => { const d = asObj(data) - return titled(`Active account: ${displayName(d)}`, addressPairs(d)) + const identity = titled( + `${d.active === true ? "Active" : "Selected"} account: ${displayName(d)}`, + addressPairs(d), + ) + return d.receiveQr + ? `${identity}\n\n${String(d.receiveQr)}\nReceive address ${String(d.receiveAddress ?? "")}` + : identity }) satisfies TextFormatter, walletRename: ((data) => { const d = asObj(data) diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index 8421a41ab..0feffe8cc 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -68,7 +68,7 @@ export function buildCli(opts: ShellOptions): Argv { if (hasVerbs) { // group with sub-verbs (e.g. import mnemonic|private-key|ledger|watch) cli.command( - `${head} [verb]`, + `${head} [verb] [args..]`, `${head} commands`, (y) => applyCommands(y, cmds.map((c) => c.fields)), (argv) => dispatchNeutral(opts, [head, typeof argv.verb === "string" ? argv.verb : undefined].filter(Boolean) as string[], argv), @@ -117,7 +117,7 @@ export function buildCli(opts: ShellOptions): Argv { continue } cli.command( - `${group} [verb]`, + `${group} [verb] [args..]`, `${group} commands`, (y) => applyCommands(y, fieldsOfLogicalGroup(group)), (argv) => dispatchLogical(opts, [group, typeof argv.verb === "string" ? argv.verb : undefined].filter(Boolean) as string[], argv), @@ -153,11 +153,45 @@ async function dispatchLogical(opts: ShellOptions, path: string[], argv: any): P throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`) } +/** + * A yargs group has one positional layout while each leaf can have a different one. + * Capture the group tail as args[], then bind it only after resolving the exact leaf. + */ +export function bindGroupedPositionals( + command: Pick, + argv: Record, +): Record { + if (!("args" in argv)) return argv + const bound = { ...argv } + const raw = bound.args + delete bound.args + const values = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw] + const declared = command.positionals ?? [] + if (values.length > declared.length) { + throw new UsageError( + "usage_error", + `too many positional arguments for ${command.path.join(" ")}: expected ${declared.length}, received ${values.length}`, + ) + } + values.forEach((value, index) => { + const field = declared[index]!.field + if (bound[field] !== undefined) { + throw new UsageError( + "invalid_option", + `${field} was provided both positionally and as --${camelToKebab(field)}`, + ) + } + bound[field] = value + }) + return bound +} + async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefinition, argv: any): Promise { const { globals, deps, targetResolver, caps, streams, formatter, session } = opts const { spec } = def const id = commandId({ path: spec.path }) session.current = { commandId: id } + argv = bindGroupedPositionals(spec, argv) const target = targetResolver.resolve(spec, globals) const net = requireResolvedNetwork(spec, target.network) @@ -196,6 +230,7 @@ async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefiniti async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: any): Promise { const { globals, deps, targetResolver, caps, streams, formatter, session } = opts session.current = { commandId: commandId(cmd) } + argv = bindGroupedPositionals(cmd, argv) assertKnownFlags(cmd, argv) // Gate all interactive prompts (gap-fill, password, secret, account-select, delete confirm) on a diff --git a/ts/src/adapters/inbound/cli/shell/shell.test.ts b/ts/src/adapters/inbound/cli/shell/shell.test.ts index 2aecb8e54..d83684cab 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.test.ts @@ -1,10 +1,46 @@ import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; -import { gapFillRequiredFields, isInteractiveCommand } from "./index.js"; +import { + bindGroupedPositionals, + gapFillRequiredFields, + isInteractiveCommand, +} from "./index.js"; import { accountRef } from "../arity/index.js"; import type { CommandDefinition } from "../contracts/index.js"; import type { Prompter as PrompterType } from "../input/prompt/index.js"; +describe("grouped leaf positionals", () => { + const command = { + path: ["contact", "add"], + positionals: [{ field: "name" }, { field: "address" }], + }; + + it("binds args to the resolved leaf's declared fields", () => { + expect(bindGroupedPositionals(command, { + verb: "add", + args: ["alice", "Taddress"], + })).toMatchObject({ + verb: "add", + name: "alice", + address: "Taddress", + }); + }); + + it("rejects too many or ambiguously duplicated arguments", () => { + expect(() => + bindGroupedPositionals(command, { + args: ["alice", "Taddress", "extra"], + }) + ).toThrow(/too many positional/); + expect(() => + bindGroupedPositionals(command, { + args: ["alice"], + name: "bob", + }) + ).toThrow(/both positionally/); + }); +}); + // ── fake prompter ───────────────────────────────────────────────────────────── interface FakePrompterOpts { diff --git a/ts/src/adapters/inbound/cli/stream/index.ts b/ts/src/adapters/inbound/cli/stream/index.ts index 0db40e728..bb4b1d639 100644 --- a/ts/src/adapters/inbound/cli/stream/index.ts +++ b/ts/src/adapters/inbound/cli/stream/index.ts @@ -9,13 +9,14 @@ import type { OutputMode } from "../../../../domain/types/index.js"; import type { DiagnosticLevel, StreamManager as IStreamManager, + WarningItem, } from "../contracts/index.js"; import { ExecutionError } from "../../../../domain/errors/index.js"; export class StreamManager implements IStreamManager { #resultWritten = false; #stdinRead = false; - #warnings: string[] = []; + #warnings: WarningItem[] = []; constructor( private readonly output: OutputMode, @@ -32,14 +33,14 @@ export class StreamManager implements IStreamManager { this.out(text.endsWith("\n") ? text : text + "\n"); } - diagnostic(level: DiagnosticLevel, msg: string): void { + diagnostic(level: DiagnosticLevel, msg: WarningItem): void { if (level === "warn") { this.#warnings.push(msg); - if (this.output === "text") this.err(`warning: ${msg}\n`); + if (this.output === "text") this.err(`warning: ${typeof msg === "string" ? msg : msg.message}\n`); return; } if (level === "debug" && !this.verbose) return; - this.err(`${msg}\n`); + this.err(`${typeof msg === "string" ? msg : msg.message}\n`); } errorLine(msg: string): void { @@ -56,7 +57,7 @@ export class StreamManager implements IStreamManager { this.err(frame.endsWith("\n") ? frame : frame + "\n"); } - warnings(): string[] { + warnings(): WarningItem[] { return this.#warnings; } diff --git a/ts/src/adapters/inbound/cli/stream/stream.test.ts b/ts/src/adapters/inbound/cli/stream/stream.test.ts index e46a00d36..cf46a5e64 100644 --- a/ts/src/adapters/inbound/cli/stream/stream.test.ts +++ b/ts/src/adapters/inbound/cli/stream/stream.test.ts @@ -48,6 +48,18 @@ describe("StreamManager", () => { expect(t.err).toEqual(["warning: careful\n"]); }); + it("preserves structured warning codes in JSON and prints only the message in text mode", () => { + const warning = { code: "owner_lockout", message: "local owner key is unavailable" }; + const j = capture("json"); + j.sm.diagnostic("warn", warning); + expect(j.sm.warnings()).toEqual([warning]); + expect(j.err).toEqual([]); + + const t = capture("text"); + t.sm.diagnostic("warn", warning); + expect(t.err).toEqual(["warning: local owner key is unavailable\n"]); + }); + it("event writes an intermediate frame as a plain line to stderr, never stdout", () => { const { sm, out, err } = capture("json"); sm.event('{"type":"signed"}'); diff --git a/ts/src/adapters/outbound/chain/tron/account-builders.test.ts b/ts/src/adapters/outbound/chain/tron/account-builders.test.ts new file mode 100644 index 000000000..33034c9af --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/account-builders.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const TARGET = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +function client(): TronRpcClient { + const client = new TronRpcClient("https://node.invalid", 100); + client.tronweb.trx.getCurrentRefBlockParams = (async () => ({ + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 1_900_000_060_000, + timestamp: 1_900_000_000_000, + })) as never; + return client; +} + +function valueOf(transaction: unknown): Record { + return ( + transaction as { + raw_data: { + contract: Array<{ parameter: { value: Record } }>; + }; + } + ).raw_data.contract[0]!.parameter.value; +} + +describe("TRON local account transaction builders", () => { + it("builds AccountCreateContract with the exact owner and target", async () => { + const gateway = client(); + const transaction = await gateway.buildAccountCreate(OWNER, TARGET); + const decoded = gateway.decodeTransactionHex( + gateway.encodeTransactionHex(transaction), + ); + + expect(decoded.raw_data.contract[0]?.type).toBe("AccountCreateContract"); + expect(valueOf(decoded)).toMatchObject({ + owner_address: gateway.tronweb.address.toHex(OWNER).toUpperCase(), + account_address: gateway.tronweb.address.toHex(TARGET).toUpperCase(), + }); + }); + + it("preserves a UTF-8 account name through complete protobuf encoding", async () => { + const gateway = client(); + const name = "金库-A"; + const transaction = await gateway.buildAccountUpdate(OWNER, name); + const decoded = gateway.decodeTransactionHex( + gateway.encodeTransactionHex(transaction), + ); + + expect(decoded.raw_data.contract[0]?.type).toBe("AccountUpdateContract"); + expect(String(valueOf(decoded).account_name).toLowerCase()).toBe( + Buffer.from(name, "utf8").toString("hex"), + ); + }); + + it("accepts and losslessly encodes a 30-byte ID despite TronWeb 6.4's hex-length bug", async () => { + const gateway = client(); + const accountId = "账".repeat(10); + expect(Buffer.byteLength(accountId, "utf8")).toBe(30); + + const transaction = await gateway.buildSetAccountId(OWNER, accountId); + const decoded = gateway.decodeTransactionHex( + gateway.encodeTransactionHex(transaction), + ); + + expect(decoded.raw_data.contract[0]?.type).toBe("SetAccountIdContract"); + expect(String(valueOf(decoded).account_id).toLowerCase()).toBe( + Buffer.from(accountId, "utf8").toString("hex"), + ); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts new file mode 100644 index 000000000..f2757efef --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + decodeTransactionHex, + encodeTransactionHex, + normalizeTransactionHex, +} from "./transaction-codec.js"; + +const OWNER = "411111111111111111111111111111111111111111"; +const OTHER = "412222222222222222222222222222222222222222"; +const OPERATIONS = "00".repeat(32); +const SIGNATURE_A = "ab".repeat(65); +const SIGNATURE_B = "cd".repeat(65); + +function fixture(type: string, value: Record) { + return { + visible: false, + raw_data: { + contract: [{ + parameter: { value, type_url: `type.googleapis.com/protocol.${type}` }, + type, + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 1_900_000_000_000, + timestamp: 1_899_999_000_000, + fee_limit: 10_000_000, + }, + signature: [SIGNATURE_A, SIGNATURE_B], + }; +} + +const CASES: Array<[string, Record]> = [ + ["AccountCreateContract", { owner_address: OWNER, account_address: OTHER }], + ["AccountUpdateContract", { owner_address: OWNER, account_name: "41636d65205472656173757279" }], + ["SetAccountIdContract", { owner_address: OWNER, account_id: "61636d652d74726561737572792d3031" }], + ["TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 123 }], + ["TransferAssetContract", { owner_address: OWNER, to_address: OTHER, asset_name: "31303030303031", amount: 456 }], + ["TriggerSmartContract", { owner_address: OWNER, contract_address: OTHER, data: "a9059cbb", call_value: 0 }], + ["FreezeBalanceV2Contract", { owner_address: OWNER, frozen_balance: 1_000_000, resource: "ENERGY" }], + ["UnfreezeBalanceV2Contract", { owner_address: OWNER, unfreeze_balance: 1_000_000, resource: "BANDWIDTH" }], + ["WithdrawExpireUnfreezeContract", { owner_address: OWNER }], + ["DelegateResourceContract", { owner_address: OWNER, receiver_address: OTHER, balance: 1_000_000, resource: "ENERGY", lock: true, lock_period: 86_400 }], + ["UnDelegateResourceContract", { owner_address: OWNER, receiver_address: OTHER, balance: 1_000_000, resource: "BANDWIDTH" }], + ["CancelAllUnfreezeV2Contract", { owner_address: OWNER }], + ["VoteWitnessContract", { owner_address: OWNER, votes: [{ vote_address: OTHER, vote_count: 7 }] }], + ["WithdrawBalanceContract", { owner_address: OWNER }], + ["CreateSmartContract", { + owner_address: OWNER, + new_contract: { + origin_address: OWNER, + abi: { entrys: [] }, + bytecode: "60006000", + call_value: 0, + consume_user_resource_percent: 100, + name: "Fixture", + origin_energy_limit: 10_000_000, + }, + }], + ["AccountPermissionUpdateContract", { + owner_address: OWNER, + owner: { type: 0, id: 0, permission_name: "owner", threshold: 2, keys: [{ address: OWNER, weight: 1 }, { address: OTHER, weight: 1 }] }, + actives: [{ type: 2, id: 2, permission_name: "active", threshold: 1, operations: OPERATIONS, keys: [{ address: OWNER, weight: 1 }] }], + }], +]; + +describe("TRON complete transaction hex codec", () => { + it.each(CASES)("round-trips %s byte-for-byte", (type, value) => { + const hex = encodeTransactionHex(fixture(type, value)); + const decoded = decodeTransactionHex(hex); + + expect(decoded.raw_data.contract).toHaveLength(1); + expect(decoded.raw_data.contract[0]?.type).toBe(type); + expect(decoded.raw_data.contract[0]?.Permission_id).toBe(2); + expect(decoded.raw_data.expiration).toBe(1_900_000_000_000); + expect(decoded.signature).toEqual([SIGNATURE_A, SIGNATURE_B]); + expect(decoded.txID).toMatch(/^[0-9a-f]{64}$/); + expect(encodeTransactionHex(decoded)).toBe(hex); + }); + + it("rejects malformed, oversized, and multi-contract inputs", () => { + expect(() => normalizeTransactionHex("0xabc")).toThrowError(/even length/); + expect(() => normalizeTransactionHex("zz")).toThrowError(/non-hex/); + expect(() => normalizeTransactionHex("00".repeat(512 * 1024 + 1))).toThrowError(/512 KiB/); + + const transaction = fixture("TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 1 }); + transaction.raw_data.contract.push(transaction.raw_data.contract[0]!); + expect(() => encodeTransactionHex(transaction)).toThrowError(/exactly one contract/); + }); + + it("rejects forged identity fields and malformed signatures", () => { + const transaction = fixture("TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 1 }); + expect(() => encodeTransactionHex({ ...transaction, txID: "00".repeat(32) })).toThrowError(/txID does not match/); + expect(() => encodeTransactionHex({ ...transaction, raw_data_hex: "00" })).toThrowError(/raw_data_hex does not match/); + expect(() => encodeTransactionHex({ ...transaction, signature: ["aa"] })).toThrowError(/65 bytes/); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts new file mode 100644 index 000000000..161c526db --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts @@ -0,0 +1,224 @@ +import { utils as tronUtils } from "tronweb"; +import { ChainError } from "../../../../domain/errors/index.js"; +import type { TronTransactionArtifact } from "../../../../domain/types/index.js"; + +const MAX_TRANSACTION_BYTES = 512 * 1024; +const SIGNATURE_BYTES = 65; + +interface ProtobufTransaction { + serializeBinary(): Uint8Array; + getRawData(): { + serializeBinary(): Uint8Array; + getContractList(): Array<{ getType(): number }>; + }; + getSignatureList_asU8(): Uint8Array[]; + addSignature(value: Uint8Array): unknown; +} + +interface TransactionProtoConstructor { + deserializeBinary(bytes: Uint8Array): ProtobufTransaction; +} + +const CONTRACT_TYPE_BY_ID: Readonly> = Object.freeze({ + 0: "AccountCreateContract", + 1: "TransferContract", + 2: "TransferAssetContract", + 4: "VoteWitnessContract", + 5: "WitnessCreateContract", + 6: "AssetIssueContract", + 8: "WitnessUpdateContract", + 9: "ParticipateAssetIssueContract", + 10: "AccountUpdateContract", + 11: "FreezeBalanceContract", + 12: "UnfreezeBalanceContract", + 13: "WithdrawBalanceContract", + 14: "UnfreezeAssetContract", + 15: "UpdateAssetContract", + 16: "ProposalCreateContract", + 17: "ProposalApproveContract", + 18: "ProposalDeleteContract", + 19: "SetAccountIdContract", + 30: "CreateSmartContract", + 31: "TriggerSmartContract", + 33: "UpdateSettingContract", + 41: "ExchangeCreateContract", + 42: "ExchangeInjectContract", + 43: "ExchangeWithdrawContract", + 44: "ExchangeTransactionContract", + 45: "UpdateEnergyLimitContract", + 46: "AccountPermissionUpdateContract", + 48: "ClearABIContract", + 49: "UpdateBrokerageContract", + 52: "MarketSellAssetContract", + 53: "MarketCancelOrderContract", + 54: "FreezeBalanceV2Contract", + 55: "UnfreezeBalanceV2Contract", + 56: "WithdrawExpireUnfreezeContract", + 57: "DelegateResourceContract", + 58: "UnDelegateResourceContract", + 59: "CancelAllUnfreezeV2Contract", +}); + +function invalidTransaction(message: string): never { + throw new ChainError("invalid_transaction", message); +} + +function normalizeHash(value: unknown, field: string): string { + if (typeof value !== "string" || !/^(?:0x)?[0-9a-fA-F]{64}$/.test(value)) { + return invalidTransaction(`${field} must be a 32-byte hex value`); + } + return value.replace(/^0x/i, "").toLowerCase(); +} + +function transactionProto(): TransactionProtoConstructor { + const proto = (globalThis as typeof globalThis & { + TronWebProto?: { Transaction?: TransactionProtoConstructor }; + }).TronWebProto?.Transaction; + if (!proto) { + throw new ChainError( + "provider_error", + "the installed TronWeb version does not expose the transaction protobuf codec", + ); + } + return proto; +} + +/** Bound and normalize untrusted protocol.Transaction hex before allocation or parsing. */ +export function normalizeTransactionHex(input: string): string { + if (typeof input !== "string") return invalidTransaction("transaction hex must be text"); + const normalized = input.trim().replace(/^0x/i, ""); + if (normalized.length === 0) return invalidTransaction("transaction hex is empty"); + if (normalized.length % 2 !== 0) return invalidTransaction("transaction hex must have an even length"); + if (!/^[0-9a-fA-F]+$/.test(normalized)) { + return invalidTransaction("transaction hex contains non-hex characters"); + } + if (normalized.length / 2 > MAX_TRANSACTION_BYTES) { + return invalidTransaction("transaction hex exceeds the 512 KiB limit"); + } + return normalized.toLowerCase(); +} + +function appendSignatures(pb: ProtobufTransaction, signatures: unknown): void { + if (signatures === undefined) return; + if (!Array.isArray(signatures)) return invalidTransaction("transaction signatures must be an array"); + for (const signature of signatures) { + if (typeof signature !== "string" || !/^(?:0x)?[0-9a-fA-F]+$/.test(signature)) { + return invalidTransaction("transaction signature must be hex"); + } + const normalized = signature.replace(/^0x/i, ""); + if (normalized.length !== SIGNATURE_BYTES * 2) { + return invalidTransaction("transaction signature must be exactly 65 bytes"); + } + pb.addSignature(Uint8Array.from(Buffer.from(normalized, "hex"))); + } +} + +/** Encode JSON into complete protocol.Transaction bytes, preserving existing signatures. */ +export function encodeTransactionHex(transaction: unknown): string { + if (!transaction || typeof transaction !== "object") { + return invalidTransaction("transaction must be an object"); + } + const candidate = transaction as Partial; + if (!candidate.raw_data || !Array.isArray(candidate.raw_data.contract) || candidate.raw_data.contract.length !== 1) { + return invalidTransaction("exactly one contract is required per transaction"); + } + let pb: ProtobufTransaction; + try { + pb = tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction; + } catch { + return invalidTransaction("transaction JSON cannot be encoded as TRON protobuf"); + } + + const computedRawDataHex = tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(); + const computedTxId = tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(); + if (candidate.raw_data_hex !== undefined) { + const suppliedRawDataHex = normalizeTransactionHex(candidate.raw_data_hex); + if (suppliedRawDataHex !== computedRawDataHex) { + return invalidTransaction("raw_data_hex does not match raw_data"); + } + } + if (candidate.txID !== undefined && normalizeHash(candidate.txID, "txID") !== computedTxId) { + return invalidTransaction("txID does not match raw_data"); + } + + appendSignatures(pb, candidate.signature); + const encoded = Buffer.from(pb.serializeBinary()).toString("hex").toLowerCase(); + if (encoded.length / 2 > MAX_TRANSACTION_BYTES) { + return invalidTransaction("encoded transaction exceeds the 512 KiB limit"); + } + return encoded; +} + +/** + * Decode complete protocol.Transaction bytes without accepting lossy reconstruction. + * Unknown protobuf fields, multiple contracts, and unsupported contract types fail closed. + */ +export function decodeTransactionHex(input: string): TronTransactionArtifact { + const normalized = normalizeTransactionHex(input); + let pb: ProtobufTransaction; + try { + pb = transactionProto().deserializeBinary(Uint8Array.from(Buffer.from(normalized, "hex"))); + } catch { + return invalidTransaction("transaction protobuf cannot be decoded"); + } + + const raw = pb.getRawData(); + const contracts = raw?.getContractList?.() ?? []; + if (contracts.length !== 1) { + return invalidTransaction("exactly one contract is required per transaction"); + } + const contractType = CONTRACT_TYPE_BY_ID[contracts[0]!.getType()]; + if (!contractType) { + return invalidTransaction(`unsupported TRON contract type id ${contracts[0]!.getType()}`); + } + + const rawDataHex = Buffer.from(raw.serializeBinary()).toString("hex").toLowerCase(); + let rawData: TronTransactionArtifact["raw_data"]; + try { + rawData = tronUtils.deserializeTx.deserializeTransaction(contractType, rawDataHex) as TronTransactionArtifact["raw_data"]; + } catch { + return invalidTransaction(`TRON contract type ${contractType} cannot be decoded losslessly`); + } + const txID = tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(); + const signature = pb.getSignatureList_asU8().map((value) => Buffer.from(value).toString("hex").toLowerCase()); + if (signature.some((value) => value.length !== SIGNATURE_BYTES * 2)) { + return invalidTransaction("transaction contains a malformed signature"); + } + + const transaction: TronTransactionArtifact = { + visible: false, + txID, + raw_data: rawData, + raw_data_hex: rawDataHex, + ...(signature.length > 0 ? { signature } : {}), + }; + if (encodeTransactionHex(transaction) !== normalized) { + return invalidTransaction("transaction cannot be represented losslessly by this client"); + } + return transaction; +} + +/** Recompute txID and raw_data_hex after a deliberate unsigned raw_data mutation. */ +export function refreshTransactionIdentity(transaction: unknown): TronTransactionArtifact { + if (!transaction || typeof transaction !== "object") { + return invalidTransaction("transaction must be an object"); + } + const candidate = transaction as Partial; + if (Array.isArray(candidate.signature) && candidate.signature.length > 0) { + return invalidTransaction("a signed transaction cannot be mutated"); + } + let pb: ProtobufTransaction; + try { + pb = tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction; + } catch { + return invalidTransaction("transaction JSON cannot be encoded as TRON protobuf"); + } + const refreshed: TronTransactionArtifact = { + ...(candidate as TronTransactionArtifact), + visible: false, + txID: tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(), + raw_data_hex: tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(), + }; + decodeTransactionHex(encodeTransactionHex(refreshed)); + return refreshed; +} diff --git a/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts b/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts new file mode 100644 index 000000000..29ed20c21 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TronRpcClient } from "./tron.js"; +import { decodeTransactionHex, encodeTransactionHex } from "./transaction-codec.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const A_HEX = "4174472e7d35395a6b5add427eecb7f4b62ad2b071"; +const B_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("TronRpcClient account permission boundary", () => { + it("binds permission and expiration before signing, then recomputes tx identity", () => { + const original = decodeTransactionHex(encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + type: "TransferContract", + parameter: { + type_url: "type.googleapis.com/protocol.TransferContract", + value: { owner_address: A_HEX, to_address: B_HEX, amount: 1 }, + }, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: 1_900_000_000_000, + expiration: 1_900_000_060_000, + }, + })); + const prepared = new TronRpcClient("https://node.invalid", 100).prepareTransaction(original, { + permissionId: 2, + expiration: 86_400_000, + }); + expect(prepared.raw_data.contract[0]?.Permission_id).toBe(2); + expect(prepared.raw_data.expiration).toBe(1_900_086_400_000); + expect(prepared.txID).not.toBe(original.txID); + expect(original.raw_data.contract[0]?.Permission_id).toBe(0); + expect(original.raw_data.expiration).toBe(1_900_000_060_000); + expect(decodeTransactionHex(encodeTransactionHex(prepared)).txID).toBe(prepared.txID); + }); + + it("normalizes node addresses and decodes active operation bitmaps", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + address: A_HEX, + owner_permission: { + id: 0, + permission_name: "owner", + threshold: 2, + keys: [{ address: A_HEX, weight: 1 }, { address: B_HEX, weight: 1 }], + }, + active_permission: [{ + id: 2, + permission_name: "finance", + threshold: 1, + operations: "0600008000000000000000000000000000000000000000000000000000000000", + keys: [{ address: A_HEX, weight: 1 }], + }], + }), { status: 200 }))); + + const permissions = await new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A); + expect(permissions).toMatchObject({ + address: A, + owner: { + id: 0, + threshold: 2, + keys: [{ address: A, weight: 1, local: null }, { address: B, weight: 1, local: null }], + }, + witness: null, + actives: [{ + id: 2, + name: "finance", + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + }], + }); + }); + + it("materializes the omitted protobuf current_weight default as zero", async () => { + const client = new TronRpcClient("https://node.invalid", 100); + vi.spyOn(client.tronweb.trx, "getSignWeight").mockResolvedValue({ + result: { code: "NOT_ENOUGH_PERMISSION" }, + permission: { + permission_name: "owner", + threshold: 1, + keys: [{ address: A_HEX, weight: 1 }], + }, + } as never); + const transaction = decodeTransactionHex(encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + type: "TransferContract", + parameter: { + type_url: "type.googleapis.com/protocol.TransferContract", + value: { + owner_address: A_HEX, + to_address: B_HEX, + amount: 1, + }, + }, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: 1_900_000_000_000, + expiration: 1_900_000_060_000, + }, + })); + + await expect( + client.getSignWeight(transaction), + ).resolves.toMatchObject({ + permission: { + id: 0, + threshold: 1, + keys: [{ address: A, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + }); + }); + + it("maps an empty account response to not_found", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 }))); + await expect(new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A)) + .rejects.toMatchObject({ code: "not_found" }); + }); + + it("materializes protocol default owner/active groups when explicit permissions are absent", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ address: A_HEX }), { status: 200 }))); + const permissions = await new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A); + expect(permissions).toMatchObject({ + owner: { id: 0, threshold: 1, keys: [{ address: A, weight: 1 }] }, + actives: [{ id: 2, name: "active", threshold: 1 }], + }); + expect(permissions.actives[0]?.operations).toContain("TransferContract"); + expect(permissions.actives[0]?.operationsHex).toBe("7fff1fc0033ef30f" + "00".repeat(24)); + }); + + it("fails closed on impossible node permission state", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + address: A_HEX, + owner_permission: { + id: 0, + permission_name: "owner", + threshold: 2, + keys: [{ address: A_HEX, weight: 1 }], + }, + active_permission: [], + }), { status: 200 }))); + await expect(new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A)) + .rejects.toMatchObject({ code: "provider_error" }); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts new file mode 100644 index 000000000..cce4223dd --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +const CONTRACT = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; + +const word = (hex: string) => hex.padStart(64, "0"); +/** ABI encoding of a dynamic `string` return: offset, length, then padded utf8 bytes. */ +const dynamicString = (text: string) => { + const bytes = Buffer.from(text, "utf8").toString("hex"); + return word("20") + word((bytes.length / 2).toString(16)) + + bytes.padEnd(Math.ceil(bytes.length / 64) * 64, "0"); +}; +/** Legacy `bytes32` return: right-padded utf8 in a single word. */ +const bytes32 = (text: string) => + Buffer.from(text, "utf8").toString("hex").padEnd(64, "0"); + +const stub = (responses: Record) => { + const client = new TronRpcClient("http://localhost:1", 200); + client.tronweb.transactionBuilder.triggerConstantContract = (( + _contract: string, fn: string, + ) => { + const hex = responses[fn]; + if (hex === undefined) return Promise.resolve({ result: { result: false, message: "" } }); + return Promise.resolve({ result: { result: true }, constant_result: [hex] }); + }) as never; + return client; +}; + +describe("TronRpcClient.getTokenInfo", () => { + it("decodes metadata from view calls without relying on a published ABI", async () => { + const client = stub({ + "name()": dynamicString("Usdd Stablecoin"), + "symbol()": dynamicString("USDD"), + "decimals()": word("12"), + "totalSupply()": word("de0b6b3a7640000"), + }); + + await expect(client.getTokenInfo(CONTRACT)).resolves.toEqual({ + contract: CONTRACT, + name: "Usdd Stablecoin", + symbol: "USDD", + decimals: 18, + totalSupply: "1000000000000000000", + }); + }); + + it("decodes legacy bytes32 name/symbol returns", async () => { + const client = stub({ + "name()": bytes32("Legacy Token"), + "symbol()": bytes32("LEG"), + "decimals()": word("6"), + "totalSupply()": word("64"), + }); + + await expect(client.getTokenInfo(CONTRACT)).resolves.toMatchObject({ + name: "Legacy Token", + symbol: "LEG", + decimals: 6, + totalSupply: "100", + }); + }); + + it("leaves fields undefined when their view call reverts", async () => { + const client = stub({ "symbol()": dynamicString("USDT") }); + + await expect(client.getTokenInfo(CONTRACT)).resolves.toEqual({ + contract: CONTRACT, + name: undefined, + symbol: "USDT", + decimals: undefined, + totalSupply: undefined, + }); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 079dc4037..f16ecb718 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -6,7 +6,15 @@ import { TronWeb } from "tronweb"; import type { Types } from "tronweb"; import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; -import type { BroadcastResult, SignedTx } from "../../../../domain/types/index.js"; +import type { + AccountPermissionsView, + ActivePermissionView, + BroadcastResult, + PermissionGroupView, + SignedTx, + TronTransactionArtifact, + UnsignedTx, +} from "../../../../domain/types/index.js"; import type { RpcResourceCode } from "../../../../domain/resources/index.js"; import type { Broadcaster } from "../../../../application/ports/chain/broadcaster.js"; import type { @@ -17,6 +25,7 @@ import type { TronDelegatedResource, TronGateway, TronNodeInfo, + TronSignWeight, TronTokenInfo, TronTx, TronTxInfo, @@ -31,9 +40,18 @@ import { parseTronTx, parseTronTxInfo } from "./tron-responses.js"; import { assertBuiltTx } from "./tx-guard.js"; import { decodeTronTransaction } from "./transaction-decoder.js"; import { isDeployedContract, normalizeContractResponses } from "./contract-response.js"; +import { + decodeTransactionHex, + encodeTransactionHex, + normalizeTransactionHex, + refreshTransactionIdentity, +} from "./transaction-codec.js"; +import { decodeOperations } from "../../../../domain/permission/index.js"; +import { addressCodec } from "../../../../domain/family/index.js"; /** a valid base58 owner used as the caller for read-only (constant) contract calls. */ const TRON_READ_OWNER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const DEFAULT_ACTIVE_OPERATIONS = "7fff1fc0033ef30f000000000000000000000000000000000000000000000000"; /** 41-prefixed hex TRON address → base58; passes through values that are already base58/empty. * TronGrid's native /transactions endpoint returns hex even with visible=true. */ @@ -80,6 +98,189 @@ export class TronRpcClient implements TronGateway, Broadcaster { return { txId: res.txid ?? res.transaction?.txID }; } + prepareTransaction( + transaction: UnsignedTx, + options: { permissionId: number; expiration?: number }, + ): TronTransactionArtifact { + if (!transaction || typeof transaction !== "object") { + throw new ChainError("invalid_transaction", "TRON builder returned a non-object transaction"); + } + const prepared = structuredClone(transaction) as TronTransactionArtifact; + if (Array.isArray(prepared.signature) && prepared.signature.length > 0) { + throw new ChainError("invalid_transaction", "cannot prepare a transaction that is already signed"); + } + const contracts = prepared.raw_data?.contract; + if (!Array.isArray(contracts) || contracts.length !== 1) { + throw new ChainError("invalid_transaction", "exactly one TRON contract is required"); + } + if (options.permissionId === 0) delete contracts[0]!.Permission_id; + else contracts[0]!.Permission_id = options.permissionId; + if (options.expiration !== undefined) { + const timestamp = prepared.raw_data.timestamp; + if (!Number.isSafeInteger(timestamp)) { + throw new ChainError("invalid_transaction", "TRON transaction timestamp is missing or imprecise"); + } + const expiration = timestamp! + options.expiration; + if (!Number.isSafeInteger(expiration)) { + throw new ChainError("invalid_transaction", "TRON transaction expiration is imprecise"); + } + prepared.raw_data.expiration = expiration; + } + return refreshTransactionIdentity(prepared); + } + + encodeTransactionHex(transaction: UnsignedTx): string { + return encodeTransactionHex(transaction); + } + + decodeTransactionHex(hex: string): TronTransactionArtifact { + return decodeTransactionHex(hex); + } + + async getAccountPermissions(address: string): Promise { + const account = await this.getAccount(address); + const returnedAddress = hexToBase58(account.address); + if (!returnedAddress) { + throw new ChainError("not_found", `TRON account is not activated: ${address}`); + } + if (returnedAddress !== address) { + throw new ChainError("provider_error", "TRON node returned permissions for a different account"); + } + const ownerRaw = account.owner_permission; + const activeRaw = account.active_permission; + if (!ownerRaw && activeRaw === undefined) { + const decoded = decodeOperations(DEFAULT_ACTIVE_OPERATIONS); + const defaultGroup = (id: number, name: string): PermissionGroupView => ({ + id, + name, + threshold: 1, + keys: [{ address, weight: 1, local: null }], + }); + const isWitness = account.is_witness === true || account.isWitness === true; + return { + address, + owner: defaultGroup(0, "owner"), + witness: isWitness ? defaultGroup(1, "witness") : null, + actives: [{ + ...defaultGroup(2, "active"), + operations: decoded.operations, + operationLabels: decoded.labels, + operationsHex: decoded.operationsHex, + unknownOperationIds: decoded.unknownOperationIds, + }], + }; + } + if (!ownerRaw) { + throw new ChainError("provider_error", "TRON node response is missing owner_permission"); + } + if (activeRaw !== undefined && !Array.isArray(activeRaw)) { + throw new ChainError("provider_error", "TRON node returned malformed active_permission data"); + } + if ((activeRaw?.length ?? 0) > 8) { + throw new ChainError("provider_error", "TRON node returned more than 8 active permissions"); + } + const owner = permissionGroupFromNode(ownerRaw, "owner", 0, { expectedId: 0 }); + const witness = account.witness_permission + ? permissionGroupFromNode(account.witness_permission, "witness", 1, { expectedId: 1, exactKeys: 1 }) + : null; + const actives = (activeRaw ?? []).map((permission, index) => activePermissionFromNode(permission, index)); + if (new Set(actives.map((permission) => permission.id)).size !== actives.length) { + throw new ChainError("provider_error", "TRON node returned duplicate active permission ids"); + } + return { address, owner, witness, actives }; + } + + async buildAccountPermissionUpdate(owner: string, permissions: AccountPermissionsView): Promise { + const toPermission = (permission: PermissionGroupView, type: number): Types.Permission => ({ + type, + id: permission.id, + permission_name: permission.name, + threshold: permission.threshold, + keys: permission.keys.map((key) => ({ address: key.address, weight: key.weight })), + }); + const ownerPermission = toPermission(permissions.owner, 0); + const witnessPermission = permissions.witness ? toPermission(permissions.witness, 1) : null; + const actives = permissions.actives.map((permission): Types.Permission => ({ + ...toPermission(permission, 2), + operations: permission.operationsHex, + })); + return this.#wrap("update account permissions", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.updateAccountPermissions( + owner, + ownerPermission, + witnessPermission, + actives, + ), + "AccountPermissionUpdateContract", + ), + ); + } + + async getSignWeight(transaction: UnsignedTx): Promise { + return this.#wrap("getSignWeight", async () => { + // TronWeb mutates Permission_id when absent; never expose the caller's artifact by reference. + const response = await this.#tw.trx.getSignWeight(structuredClone(transaction) as Types.Transaction); + const permission = response.permission + ? permissionGroupFromNode(response.permission, "permission", Number(response.permission.id ?? 0)) + : null; + return { + permission: permission ? { + id: permission.id, + name: permission.name, + threshold: permission.threshold, + operationsHex: typeof response.permission.operations === "string" + ? response.permission.operations.toLowerCase() + : undefined, + keys: permission.keys.map(({ address: keyAddress, weight }) => ({ address: keyAddress, weight })), + } : null, + approvedList: (response.approved_list ?? []).map(hexToBase58), + // Protobuf JSON omits scalar zero values. An unsigned transaction therefore has no + // current_weight field even though the protocol value is exactly 0. + currentWeight: safeNodeInteger(response.current_weight ?? 0, "current_weight"), + resultCode: String(response.result?.code ?? ""), + message: decodeTronMessage(response.result?.message), + }; + }); + } + + async getApprovedList(transaction: UnsignedTx): Promise { + return this.#wrap("getApprovedList", async () => { + const response = await this.#tw.trx.getApprovedList(structuredClone(transaction) as Types.Transaction); + return (response.approved_list ?? []).map(hexToBase58); + }); + } + + async broadcastHex(input: string): Promise { + const hex = normalizeTransactionHex(input); + decodeTransactionHex(hex); + const response = await this.#wrap("broadcast hex", () => this.#tw.trx.sendHexTransaction(hex)); + if (response.result === false) { + const reason = decodeTronMessage(response.message) || response.code || "rejected by node"; + throw new ChainError("transaction_rejected", `TRON broadcast rejected: ${redactErrorMessage(String(reason))}`); + } + const returnedTxId = response.transaction && typeof response.transaction === "object" + ? response.transaction.txID + : undefined; + return { txId: response.txid ?? returnedTxId }; + } + + async getUpdateAccountPermissionFee(): Promise { + return this.#chainParameter("getUpdateAccountPermissionFee"); + } + + async getMultiSignFee(): Promise { + return this.#chainParameter("getMultiSignFee"); + } + + async #chainParameter(key: string): Promise { + const parameter = (await this.getChainParameters()).find((entry) => entry.key === key); + if (parameter?.value === undefined) { + throw new ChainError("provider_error", `TRON chain parameter is unavailable: ${key}`); + } + return safeNodeInteger(parameter.value, key); + } + /** build an unsigned TRX transfer (tronweb fills ref block etc.). */ async buildNativeTransfer(from: string, to: string, amountSun: string): Promise { // tronweb's sendTrx amount param is a JS number; guard before #wrap so a precision-losing @@ -90,6 +291,65 @@ export class TronRpcClient implements TronGateway, Broadcaster { ); } + async buildAccountCreate(owner: string, target: string): Promise { + return this.#localAccountTransaction("AccountCreateContract", owner, { + owner_address: this.#tw.address.toHex(owner), + account_address: this.#tw.address.toHex(target), + }); + } + + async buildAccountUpdate(owner: string, accountName: string): Promise { + return this.#localAccountTransaction("AccountUpdateContract", owner, { + owner_address: this.#tw.address.toHex(owner), + account_name: Buffer.from(accountName, "utf8").toString("hex"), + }); + } + + async buildSetAccountId(owner: string, accountId: string): Promise { + // TronWeb 6.4 applies the 8..32 bound to the encoded hex string length, not the decoded + // UTF-8 byte length. Build the identical protobuf locally so valid 17..32-byte ids survive. + return this.#localAccountTransaction("SetAccountIdContract", owner, { + owner_address: this.#tw.address.toHex(owner), + account_id: Buffer.from(accountId, "utf8").toString("hex"), + }); + } + + async #localAccountTransaction( + type: "AccountCreateContract" | "AccountUpdateContract" | "SetAccountIdContract", + owner: string, + value: Record, + ): Promise { + return this.#wrap(`build ${type}`, async () => { + const block = await this.#tw.trx.getCurrentRefBlockParams(); + const transaction = refreshTransactionIdentity({ + visible: false, + txID: "", + raw_data_hex: "", + raw_data: { + contract: [{ + parameter: { + value, + type_url: `type.googleapis.com/protocol.${type}`, + }, + type, + }], + ...block, + }, + }); + const contract = transaction.raw_data.contract[0]?.parameter?.value; + const matchesIntent = Object.entries(value).every( + ([key, expected]) => contract?.[key] === expected, + ); + if (!matchesIntent) { + throw new ChainError( + "tx_integrity", + `locally built ${type} fields do not match the requested operation`, + ); + } + return assertBuiltTx(transaction, type); + }); + } + // ── generic error wrapper for node reads/builds ────────────────────────────── // Timeout wraps the guard (not the reverse) so a timed-out call surfaces as ChainError("timeout") // rather than being remapped to a generic rpc_error by the catch below. @@ -100,6 +360,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { try { return await fn(); } catch (e) { + if (e instanceof ChainError || e instanceof UsageError || e instanceof TransportError) throw e; throw new TransportError("rpc_error", `TRON ${label} failed: ${redactErrorMessage((e as Error).message?.split("\n")[0] ?? "")}`); } } @@ -117,6 +378,20 @@ export class TronRpcClient implements TronGateway, Broadcaster { return parseTronAccountResponse(await response.text()); }); } + async getAccountById(accountId: string): Promise { + return this.#wrap("getAccountById", async () => { + const response = await fetch(`${this.#fullHost}/wallet/getaccountbyid`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + account_id: Buffer.from(accountId, "utf8").toString("hex"), + }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return parseTronAccountResponse(await response.text()); + }); + } async getAccountResources(address: string): Promise { return this.#wrap("getAccountResources", () => this.#tw.trx.getAccountResources(address)); } @@ -161,19 +436,20 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getTokenInfo(contract: string): Promise { return this.#wrap("trc20 tokenInfo", async () => { - const c = await this.#tw.contract().at(contract); + // Read the view methods by selector rather than via contract().at(): tokens deployed without a + // published ABI (e.g. USDD on Nile) resolve to a contract object with no methods at all. + const read = async (fn: string): Promise => + this.#constant(contract, fn, []).then(([hex]) => hex).catch(() => undefined); const [name, symbol, decimals, totalSupply] = await Promise.all([ - c.name().call().catch(() => undefined), - c.symbol().call().catch(() => undefined), - c.decimals().call().catch(() => undefined), - c.totalSupply().call().catch(() => undefined), + read("name()"), read("symbol()"), read("decimals()"), read("totalSupply()"), ]); + const scale = decodeAbiUint(decimals); return { contract, - name: name?.toString?.() ?? name, - symbol: symbol?.toString?.() ?? symbol, - decimals: decimals !== undefined ? Number(decimals) : undefined, - totalSupply: totalSupply !== undefined ? BigInt(totalSupply.toString()).toString() : undefined, + name: decodeAbiString(name), + symbol: decodeAbiString(symbol), + decimals: scale !== undefined && scale <= 255n ? Number(scale) : undefined, + totalSupply: decodeAbiUint(totalSupply)?.toString(), }; }); } @@ -522,6 +798,99 @@ export function parseTronAccountResponse(text: string): TronAccount { return normalizeAccountValue(parseLosslessJson(text)) as TronAccount; } +function safeNodeInteger(value: unknown, field: string): number { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed)) return parsed; + } + throw new ChainError("provider_error", `TRON node returned an imprecise or invalid ${field}`); +} + +function safePermissionName(value: unknown, fallback: string, field: string): string { + const name = value === undefined ? fallback : value; + if (typeof name !== "string" + || name.length === 0 + || Buffer.byteLength(name, "utf8") > 32 + || /\p{Cc}/u.test(name)) { + throw new ChainError("provider_error", `TRON node returned an invalid ${field}`); + } + return name; +} + +function permissionGroupFromNode( + value: unknown, + kind: string, + fallbackId: number, + constraints: { expectedId?: number; exactKeys?: number } = {}, +): PermissionGroupView { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ChainError("provider_error", `TRON node returned malformed ${kind} permission data`); + } + const raw = value as Record; + if (!Array.isArray(raw.keys) + || raw.keys.length === 0 + || raw.keys.length > 5 + || (constraints.exactKeys !== undefined && raw.keys.length !== constraints.exactKeys)) { + throw new ChainError("provider_error", `TRON node returned an invalid ${kind} key count`); + } + const threshold = safeNodeInteger(raw.threshold, `${kind}.threshold`); + if (threshold === 0) throw new ChainError("provider_error", `TRON node returned zero ${kind} threshold`); + const seen = new Set(); + let totalWeight = 0n; + const keys = raw.keys.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new ChainError("provider_error", `TRON node returned malformed ${kind}.keys[${index}]`); + } + const key = entry as Record; + const address = hexToBase58(key.address); + const weight = safeNodeInteger(key.weight, `${kind}.keys[${index}].weight`); + if (!addressCodec("tron").validate(address) || weight === 0 || seen.has(address)) { + throw new ChainError("provider_error", `TRON node returned invalid ${kind}.keys[${index}]`); + } + seen.add(address); + totalWeight += BigInt(weight); + return { address, weight, local: null }; + }); + if (BigInt(threshold) > totalWeight) { + throw new ChainError("provider_error", `TRON node returned ${kind} threshold above total key weight`); + } + const id = raw.id === undefined ? fallbackId : safeNodeInteger(raw.id, `${kind}.id`); + if (constraints.expectedId !== undefined && id !== constraints.expectedId) { + throw new ChainError("provider_error", `TRON node returned invalid ${kind} permission id`); + } + return { + id, + name: safePermissionName(raw.permission_name, kind, `${kind}.permission_name`), + threshold, + keys, + }; +} + +function activePermissionFromNode(value: unknown, index: number): ActivePermissionView { + const group = permissionGroupFromNode(value, `active[${index}]`, index + 2); + if (group.id < 2 || group.id > 9) { + throw new ChainError("provider_error", `TRON node returned invalid active[${index}] permission id`); + } + const raw = value as Record; + if (typeof raw.operations !== "string") { + throw new ChainError("provider_error", `TRON node returned active[${index}] without operations`); + } + let operations: ReturnType; + try { + operations = decodeOperations(raw.operations); + } catch { + throw new ChainError("provider_error", `TRON node returned malformed active[${index}] operations`); + } + return { + ...group, + operations: operations.operations, + operationLabels: operations.labels, + operationsHex: operations.operationsHex, + unknownOperationIds: operations.unknownOperationIds, + }; +} + function normalizeAccountValue(value: unknown, key?: string): unknown { if (isLosslessNumber(value)) { const exact = value.toString(); @@ -544,6 +913,25 @@ export interface FeeEstimate extends Record { } /** TRC20 metadata read via the contract's view methods; each field absent when the call reverts. */ +/** Decode an ABI-encoded return word as an unsigned integer. */ +function decodeAbiUint(hex?: string): bigint | undefined { + if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return undefined; + return BigInt("0x" + hex); +} + +/** Decode an ABI-encoded return value as text; handles both dynamic `string` and legacy `bytes32`. */ +function decodeAbiString(hex?: string): string | undefined { + if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return undefined; + const buf = Buffer.from(hex, "hex"); + if (buf.length === 32) return buf.toString("utf8").replace(/\0[\s\S]*$/, "") || undefined; + if (buf.length < 64) return undefined; + const offset = Number(BigInt("0x" + buf.subarray(0, 32).toString("hex"))); + if (!Number.isSafeInteger(offset) || offset + 32 > buf.length) return undefined; + const length = Number(BigInt("0x" + buf.subarray(offset, offset + 32).toString("hex"))); + if (!Number.isSafeInteger(length) || offset + 32 + length > buf.length) return undefined; + return buf.subarray(offset + 32, offset + 32 + length).toString("utf8") || undefined; +} + /** tronweb error messages are often hex-encoded ASCII; decode best-effort. */ function decodeTronMessage(message?: string): string { if (!message) return ""; diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 47f59f791..95e26b6a6 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -18,9 +18,14 @@ export const CAP_SUMMARIES: Record = { "account.balance.native": "native balance", "account.balance.token": "token balance", "account.portfolio": "holdings with USD valuation", + "account.activate": "activate a new TRON account", + "account.set": "set one-time on-chain account name or ID", "token.tokenbook": "token address-book (add/list/remove)", "tx.send": "transfer native / token", + "tx.sign": "sign transaction artifacts without broadcasting", "tx.broadcast": "broadcast a presigned transaction", + "tx.multisig.local": "inspect and append local multi-sign approvals", + "tx.multisig.tronlink": "coordinate multi-sign approvals through TronLink", "message.sign": "sign a message", "contract.call": "constant + state-changing contract calls", "contract.deploy": "deploy a smart contract", @@ -31,6 +36,11 @@ export const CAP_SUMMARIES: Record = { "vote.status": "current SR votes and voting power", "reward.balance": "claimable voting/block reward", "reward.withdraw": "withdraw voting/block rewards", + "permission.read": "read account multi-sign permissions", + "permission.update": "replace account multi-sign permissions", + "gasfree.info": "GasFree account, fee and nonce information", + "gasfree.transfer": "TIP-712 gas-free token transfer", + "gasfree.trace": "track a GasFree transfer", } export const BUILTIN_NETWORKS: Record = { @@ -40,6 +50,13 @@ export const BUILTIN_NETWORKS: Record = { chainId: "mainnet", aliases: ["tron"], httpEndpoint: "https://api.trongrid.io", + tronlinkHttpEndpoint: "https://api.walletadapter.org", + gasfree: { + baseUrl: "https://open.gasfree.io", + apiPrefix: "/tron", + controllerChainId: "728126428", + verifyingContract: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", + }, feeModel: "tron-resource", capabilities: [], }, @@ -49,6 +66,13 @@ export const BUILTIN_NETWORKS: Record = { chainId: "nile", aliases: ["nile"], httpEndpoint: "https://nile.trongrid.io", + tronlinkHttpEndpoint: "https://apinile.walletadapter.org", + gasfree: { + baseUrl: "https://open-test.gasfree.io", + apiPrefix: "/nile", + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", + }, feeModel: "tron-resource", capabilities: [], }, @@ -58,6 +82,7 @@ export const BUILTIN_NETWORKS: Record = { chainId: "shasta", aliases: ["shasta"], httpEndpoint: "https://api.shasta.trongrid.io", + tronlinkHttpEndpoint: "https://apishasta.walletadapter.org", feeModel: "tron-resource", capabilities: [], }, diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 3d967681d..dd43985bd 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ConfigLoader, NetworkRegistry } from "./index.js"; -function envWithConfig(yaml: string): NodeJS.ProcessEnv { +function envWithConfig(yaml: string, mode?: number): NodeJS.ProcessEnv { const root = mkdtempSync(join(tmpdir(), "wcli-config-")); - writeFileSync(join(root, "config.yaml"), yaml); + const path = join(root, "config.yaml"); + writeFileSync(path, yaml); + if (mode !== undefined) chmodSync(path, mode); return { ...process.env, WALLET_CLI_HOME: root }; } @@ -28,6 +30,32 @@ describe("ConfigLoader defaultNetwork", () => { }); }); +describe("ConfigLoader Windows wallet root", () => { + const profile = "C:\\Users\\alice"; + const expected = "C:\\Users\\alice\\.wallet-cli"; + + it("uses USERPROFILE for the same default root in PowerShell and Git Bash", () => { + const powerShell = ConfigLoader.resolveRoot({ USERPROFILE: profile }, "win32"); + const gitBash = ConfigLoader.resolveRoot({ USERPROFILE: "/c/Users/alice", HOME: "/c/Users/alice" }, "win32"); + + expect(powerShell).toBe(expected); + expect(gitBash).toBe(expected); + }); + + it("normalizes a Cygwin-style USERPROFILE to the same native root", () => { + expect(ConfigLoader.resolveRoot({ USERPROFILE: "/cygdrive/c/Users/alice" }, "win32")).toBe(expected); + }); + + it.each([ + "C:\\Users\\alice\\.wallet-cli", + "C:/Users/alice/.wallet-cli", + "/c/Users/alice/.wallet-cli", + "/cygdrive/c/Users/alice/.wallet-cli", + ])("normalizes the explicit Windows/MSYS root %s", (root) => { + expect(ConfigLoader.resolveRoot({ WALLET_CLI_HOME: root }, "win32")).toBe(expected); + }); +}); + describe("ConfigLoader waitTimeoutMs validation", () => { it("accepts a valid non-negative integer", () => { expect(ConfigLoader.load(envWithConfig("waitTimeoutMs: 5000\n")).waitTimeoutMs).toBe(5000); @@ -57,3 +85,48 @@ describe("NetworkRegistry.resolve case-insensitivity", () => { expect(() => registry().resolve("dogechain")).toThrow(/unknown network/); }); }); + +describe("ConfigLoader TronLink credentials", () => { + const credentials = [ + "tronlinkSecretId: TEST", + "tronlinkSecretKey: TESTTESTTEST", + "tronlinkChannel: test", + "", + ].join("\n"); + + it("loads credentials only from a private config file", () => { + expect(ConfigLoader.load(envWithConfig(credentials, 0o600))).toMatchObject({ + tronlinkSecretId: "TEST", + tronlinkSecretKey: "TESTTESTTEST", + tronlinkChannel: "test", + }); + }); + + it.runIf(process.platform !== "win32")("rejects credentials in a group/world-readable file", () => { + expect(() => ConfigLoader.load(envWithConfig(credentials, 0o644))) + .toThrow(/mode 0600/); + }); +}); + +describe("ConfigLoader GasFree credentials", () => { + const credentials = [ + "gasfreeApiKey: TEST", + "gasfreeApiSecret: TESTTESTTEST", + "", + ].join("\n"); + + it("loads credentials only from a private config file", () => { + expect(ConfigLoader.load(envWithConfig(credentials, 0o600))).toMatchObject({ + gasfreeApiKey: "TEST", + gasfreeApiSecret: "TESTTESTTEST", + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects credentials in a group/world-readable file", + () => { + expect(() => ConfigLoader.load(envWithConfig(credentials, 0o644))) + .toThrow(/mode 0600/); + }, + ); +}); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index 09bd7a913..cad5c6d8d 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -3,9 +3,9 @@ * build the network registry, and resolve canonical network ids. The descriptor stays pure data; * live RPC clients are owned by the chain gateway provider, not attached here. */ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; import { parse as parseYaml } from "yaml"; import type { Config, NetworkDescriptor, OutputMode } from "../../../domain/types/index.js"; @@ -15,10 +15,17 @@ import { BUILTIN_NETWORKS, DEFAULT_CONFIG } from "./builtins.js"; export class ConfigLoader { /** bootstrap: must run before locating config.yaml. */ - static resolveRoot(env: NodeJS.ProcessEnv = process.env): string { - return env.WALLET_CLI_HOME && env.WALLET_CLI_HOME.trim() !== "" - ? env.WALLET_CLI_HOME - : join(homedir(), ".wallet-cli"); + static resolveRoot(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string { + const configured = env.WALLET_CLI_HOME?.trim(); + if (configured) return platform === "win32" ? normalizeWindowsRoot(configured) : configured; + + // USERPROFILE is the Windows account identity shared by cmd, PowerShell and Git Bash. Resolve + // it explicitly instead of delegating to runtime-specific HOME/homedir precedence. + if (platform === "win32") { + const profile = env.USERPROFILE?.trim() || homedir(); + return win32.join(normalizeWindowsRoot(profile), ".wallet-cli"); + } + return join(homedir(), ".wallet-cli"); } static configPath(env: NodeJS.ProcessEnv = process.env): string { @@ -35,10 +42,21 @@ export class ConfigLoader { let timeoutMs = DEFAULT_CONFIG.timeoutMs; let waitTimeoutMs = DEFAULT_CONFIG.waitTimeoutMs; let price: Config["price"]; + let tronlinkSecretId: string | undefined; + let tronlinkSecretKey: string | undefined; + let tronlinkChannel: string | undefined; + let gasfreeApiKey: string | undefined; + let gasfreeApiSecret: string | undefined; const path = ConfigLoader.configPath(env); if (existsSync(path)) { const raw = parseYaml(readFileSync(path, "utf8")) ?? {}; + if ( + (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") + || (typeof raw.gasfreeApiSecret === "string" && raw.gasfreeApiSecret !== "") + ) { + assertSecretConfigPermissions(path); + } if (typeof raw.defaultNetwork === "string" && raw.defaultNetwork.trim() !== "") { defaultNetwork = raw.defaultNetwork; } @@ -53,13 +71,61 @@ export class ConfigLoader { price = { provider }; if (typeof p.baseUrl === "string" && p.baseUrl.trim() !== "") price.baseUrl = p.baseUrl; } + if (validCredential(raw.tronlinkSecretId)) tronlinkSecretId = raw.tronlinkSecretId; + if (validCredential(raw.tronlinkSecretKey)) tronlinkSecretKey = raw.tronlinkSecretKey; + if (validCredential(raw.tronlinkChannel)) tronlinkChannel = raw.tronlinkChannel; + if (validCredential(raw.gasfreeApiKey)) gasfreeApiKey = raw.gasfreeApiKey; + if (validCredential(raw.gasfreeApiSecret)) gasfreeApiSecret = raw.gasfreeApiSecret; if (raw.networks && typeof raw.networks === "object") { for (const [id, d] of Object.entries(raw.networks as Record>)) { networks[id] = { ...(networks[id] ?? {}), ...d, id } as NetworkDescriptor; } } } - return { defaultNetwork, defaultOutput, timeoutMs, waitTimeoutMs, networks, price }; + return { + defaultNetwork, + defaultOutput, + timeoutMs, + waitTimeoutMs, + networks, + price, + tronlinkSecretId, + tronlinkSecretKey, + tronlinkChannel, + gasfreeApiKey, + gasfreeApiSecret, + }; + } +} + +/** Convert Git Bash/MSYS/Cygwin drive paths before the native Windows executable touches disk. */ +function normalizeWindowsRoot(input: string): string { + const slashPath = input.replaceAll("\\", "/"); + const msys = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(slashPath); + if (msys) return win32.normalize(`${msys[1]!.toUpperCase()}:\\${msys[2] ?? ""}`); + const cygwin = /^\/cygdrive\/([a-zA-Z])(?:\/(.*))?$/.exec(slashPath); + if (cygwin) return win32.normalize(`${cygwin[1]!.toUpperCase()}:\\${cygwin[2] ?? ""}`); + const resolved = win32.resolve(input); + return resolved.replace(/^([a-z]):/, (_match, drive: string) => `${drive.toUpperCase()}:`); +} + +function validCredential(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && value.length <= 256 + && !/[\u0000-\u001f\u007f]/.test(value); +} + +function assertSecretConfigPermissions(path: string): void { + if (process.platform === "win32") return; + if (lstatSync(path).isSymbolicLink()) { + throw new UsageError("insecure_config", "config.yaml containing service credentials must not be a symbolic link"); + } + if ((statSync(path).mode & 0o077) !== 0) { + throw new UsageError( + "insecure_config", + "config.yaml containing service credentials must have mode 0600; run chmod 600 on the file", + ); } } diff --git a/ts/src/adapters/outbound/contactbook/contactbook.test.ts b/ts/src/adapters/outbound/contactbook/contactbook.test.ts new file mode 100644 index 000000000..f2b78fafd --- /dev/null +++ b/ts/src/adapters/outbound/contactbook/contactbook.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + chmodSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { ContactBook } from "./index.js"; +import { AtomicFileStore } from "../persistence/fs/index.js"; +import { createContact } from "../../../domain/contact/index.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const OTHER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function root(): string { + const value = mkdtempSync(join(tmpdir(), "wallet-cli-contacts-")); + roots.push(value); + return value; +} + +describe("ContactBook", () => { + it("stores atomically at 0600 and enforces normalized-name uniqueness", () => { + const directory = root(); + const book = new ContactBook(directory, new AtomicFileStore()); + book.add(createContact("tron", "Alice", ADDRESS, "Treasury")); + + expect(book.find("tron", "alice")?.name).toBe("Alice"); + expect(() => + book.add(createContact("tron", "ALICE", OTHER)) + ).toThrow(/already exists/); + const path = join(directory, "contacts.json"); + if (process.platform !== "win32") { + expect(lstatSync(path).mode & 0o777).toBe(0o600); + } + expect(readFileSync(path, "utf8")).toContain('"nameKey": "alice"'); + expect(book.remove("tron", "alice").address).toBe(ADDRESS); + }); + + it.runIf(process.platform !== "win32")( + "refuses a contact file with group/other permissions", + () => { + const directory = root(); + const book = new ContactBook(directory, new AtomicFileStore()); + book.add(createContact("tron", "Alice", ADDRESS)); + chmodSync(join(directory, "contacts.json"), 0o644); + + expect(() => book.list("tron")).toThrow(/mode 0600/); + }, + ); + + it.runIf(process.platform !== "win32")( + "refuses a symbolic-link contact file without following it", + () => { + const directory = root(); + const external = join(directory, "external.json"); + writeFileSync( + external, + JSON.stringify({ version: 1, entries: {} }), + { mode: 0o600 }, + ); + symlinkSync(external, join(directory, "contacts.json")); + + expect(() => + new ContactBook(directory, new AtomicFileStore()).list("tron") + ).toThrow(/symbolic link/); + }, + ); + + it("rejects tampered normalization fields instead of accepting aliases", () => { + const directory = root(); + writeFileSync( + join(directory, "contacts.json"), + JSON.stringify({ + version: 1, + entries: { + tron: [{ + family: "tron", + name: "Alice", + nameKey: "bob", + address: ADDRESS, + note: null, + }], + }, + }), + { mode: 0o600 }, + ); + + expect(() => + new ContactBook(directory, new AtomicFileStore()).list("tron") + ).toThrow(/invalid schema/); + }); +}); diff --git a/ts/src/adapters/outbound/contactbook/index.ts b/ts/src/adapters/outbound/contactbook/index.ts new file mode 100644 index 000000000..051efaac0 --- /dev/null +++ b/ts/src/adapters/outbound/contactbook/index.ts @@ -0,0 +1,226 @@ +import { + closeSync, + constants, + fstatSync, + openSync, + readFileSync, +} from "node:fs"; +import { join } from "node:path"; +import type { ContactRepository } from "../../../application/ports/contact-repository.js"; +import type { + ChainFamily, + ContactEntry, +} from "../../../domain/types/index.js"; +import { + ExecutionError, + UsageError, +} from "../../../domain/errors/index.js"; +import { createContact } from "../../../domain/contact/index.js"; +import { AtomicFileStore } from "../persistence/fs/index.js"; + +const MAX_CONTACT_FILE_BYTES = 4 * 1024 * 1024; +const MAX_CONTACTS = 10_000; + +interface ContactDocument { + version: 1; + entries: Partial>; +} + +export class ContactBook implements ContactRepository { + readonly #path: string; + + constructor(root: string, private readonly store: AtomicFileStore) { + this.#path = join(root, "contacts.json"); + } + + add(entry: ContactEntry): ContactEntry { + return this.store.withLock(this.#path, () => { + const document = this.#read(); + const entries = document.entries[entry.family] ?? []; + if (entries.some((item) => item.nameKey === entry.nameKey)) { + throw new UsageError( + "already_exists", + `contact already exists: ${entry.name}`, + ); + } + if (entries.length >= MAX_CONTACTS) { + throw new UsageError( + "limit_exceeded", + `contact book cannot exceed ${MAX_CONTACTS} entries`, + ); + } + entries.push(entry); + document.entries[entry.family] = entries.sort((a, b) => + a.nameKey.localeCompare(b.nameKey) + ); + this.store.writeJson(this.#path, document); + return entry; + }); + } + + list(family: ChainFamily): ContactEntry[] { + return [...(this.#read().entries[family] ?? [])].sort((a, b) => + a.nameKey.localeCompare(b.nameKey) + ); + } + + find( + family: ChainFamily, + nameKey: string, + ): ContactEntry | undefined { + return this.list(family).find((entry) => entry.nameKey === nameKey); + } + + remove(family: ChainFamily, nameKey: string): ContactEntry { + return this.store.withLock(this.#path, () => { + const document = this.#read(); + const entries = document.entries[family] ?? []; + const index = entries.findIndex((entry) => entry.nameKey === nameKey); + if (index < 0) { + throw new UsageError( + "not_found", + `contact not found: ${nameKey}`, + ); + } + const [removed] = entries.splice(index, 1); + document.entries[family] = entries; + this.store.writeJson(this.#path, document); + return removed!; + }); + } + + #read(): ContactDocument { + const raw = this.#readSecureJson(); + if (raw === null) return { version: 1, entries: {} }; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw corrupt(); + } + const root = raw as Record; + if ( + root.version !== 1 + || !root.entries + || typeof root.entries !== "object" + || Array.isArray(root.entries) + ) { + throw corrupt(); + } + const result: ContactDocument = { version: 1, entries: {} }; + for ( + const [family, items] + of Object.entries(root.entries as Record) + ) { + if ( + family !== "tron" + || !Array.isArray(items) + || items.length > MAX_CONTACTS + ) { + throw corrupt(); + } + const seen = new Set(); + result.entries.tron = items.map((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw corrupt(); + } + const item = value as Record; + if ( + typeof item.name !== "string" + || typeof item.address !== "string" + || ( + item.note !== null + && item.note !== undefined + && typeof item.note !== "string" + ) + ) { + throw corrupt(); + } + const validated = createContact( + "tron", + item.name, + item.address, + item.note ?? undefined, + ); + if ( + item.nameKey !== validated.nameKey + || item.family !== "tron" + || seen.has(validated.nameKey) + ) { + throw corrupt(); + } + seen.add(validated.nameKey); + return validated; + }); + } + return result; + } + + /** Open with O_NOFOLLOW, then validate the opened inode to avoid lstat/read TOCTOU. */ + #readSecureJson(): unknown | null { + let descriptor: number | undefined; + try { + descriptor = openSync( + this.#path, + constants.O_RDONLY + | (constants.O_NOFOLLOW ?? 0) + | (constants.O_NONBLOCK ?? 0), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return null; + if (code === "ELOOP") { + throw new ExecutionError( + "insecure_permissions", + "contacts.json must not be a symbolic link", + ); + } + throw error; + } + try { + const stat = fstatSync(descriptor); + if (!stat.isFile()) { + throw new ExecutionError( + "encoding_error", + "contacts.json must be a regular file", + ); + } + if (stat.size > MAX_CONTACT_FILE_BYTES) { + throw new ExecutionError( + "encoding_error", + "contacts.json exceeds the 4 MiB limit", + ); + } + if (process.platform !== "win32") { + if ((stat.mode & 0o777) !== 0o600) { + throw new ExecutionError( + "insecure_permissions", + "contacts.json must have mode 0600", + ); + } + if ( + typeof process.getuid === "function" + && stat.uid !== process.getuid() + ) { + throw new ExecutionError( + "insecure_permissions", + "contacts.json must be owned by the current user", + ); + } + } + const text = readFileSync(descriptor, "utf8"); + if (text.trim() === "") return null; + try { + return JSON.parse(text) as unknown; + } catch { + throw corrupt(); + } + } finally { + closeSync(descriptor); + } + } +} + +function corrupt(): ExecutionError { + return new ExecutionError( + "encoding_error", + "contacts.json has an invalid schema", + ); +} diff --git a/ts/src/adapters/outbound/gasfree/client.test.ts b/ts/src/adapters/outbound/gasfree/client.test.ts new file mode 100644 index 000000000..aa21fc448 --- /dev/null +++ b/ts/src/adapters/outbound/gasfree/client.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + Config, + NetworkDescriptor, +} from "../../../domain/types/index.js"; +import { GasFreeClient } from "./client.js"; + +const NETWORK = { + id: "tron:nile", + family: "tron", + chainId: "nile", + aliases: ["nile"], + capabilities: [], + gasfree: { + baseUrl: "https://open-test.gasfree.io", + apiPrefix: "/nile", + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", + }, +} satisfies NetworkDescriptor; +const CONFIG = { + gasfreeApiKey: "test-key", + gasfreeApiSecret: "test-secret", +} as Config; + +function response(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("GasFreeClient", () => { + it("signs the exact Java path and parses uints losslessly", async () => { + const fetcher = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ + Timestamp: "1700000000", + Authorization: + "ApiKey test-key:0nyrTDAIARSlT+WxH69ILaVpOZ7UTkEBwEH8uxR5B2I=", + }); + return new Response( + '{"code":200,"data":{"tokens":[{"tokenAddress":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","activateFee":900719925474099312345,"transferFee":2000,"symbol":"USDT","decimals":6}]}}', + { status: 200 }, + ); + }, + ); + const client = new GasFreeClient( + CONFIG, + 1000, + fetcher as typeof fetch, + () => 1_700_000_000_000, + ); + const tokens = await client.listTokens(NETWORK); + expect(fetcher.mock.calls[0]?.[0]).toBe( + "https://open-test.gasfree.io/nile/api/v1/config/token/all", + ); + expect(tokens[0]?.activateFee).toBe("900719925474099312345"); + }); + + it("serializes uint256 values as JSON numbers and never retries POST", async () => { + const fetcher = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.body).toContain('"value":900719925474099312345'); + return response({ + code: 200, + data: { + id: "6c3ff67e-0bf4-4c09-91ca-0c7c254b01a0", + state: "WAITING", + tokenAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + providerAddress: "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E", + accountAddress: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + gasFreeAddress: "TNER12mMVWruqopsW9FQtKxCGfZcEtb3ER", + targetAddress: "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + amount: 900719925474099312345n.toString(), + maxFee: 2000000, + nonce: 8, + expiredAt: 1747909695000, + }, + }); + }, + ); + const client = new GasFreeClient( + CONFIG, + 1000, + fetcher as typeof fetch, + () => 1_700_000_000_000, + ); + const record = await client.submitTransfer(NETWORK, { + token: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + serviceProvider: "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E", + user: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + receiver: "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + value: "900719925474099312345", + maxFee: "2000000", + deadline: "1747909695", + version: "1", + nonce: "8", + sig: "00".repeat(64) + "1b", + }); + expect(record.expiredAt).toBe("1747909695000"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("maps authentication errors without leaking credentials", async () => { + const client = new GasFreeClient( + CONFIG, + 1000, + vi.fn(async () => response({}, 401)) as typeof fetch, + ); + await expect(client.listTokens(NETWORK)).rejects.toMatchObject({ + code: "gasfree_auth_failed", + }); + await expect(client.listTokens(NETWORK)).rejects.not.toThrow( + /test-secret|test-key/, + ); + }); + + it("rejects unsupported networks before sending credentials", async () => { + const fetcher = vi.fn(); + const client = new GasFreeClient(CONFIG, 1000, fetcher as typeof fetch); + await expect( + client.listTokens({ ...NETWORK, gasfree: undefined }), + ).rejects.toMatchObject({ code: "unsupported_network" }); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/outbound/gasfree/client.ts b/ts/src/adapters/outbound/gasfree/client.ts new file mode 100644 index 000000000..c015edde8 --- /dev/null +++ b/ts/src/adapters/outbound/gasfree/client.ts @@ -0,0 +1,544 @@ +import { createHmac } from "node:crypto"; +import { + isLosslessNumber, + LosslessNumber, + parse as parseLosslessJson, + stringify as stringifyLosslessJson, +} from "lossless-json"; +import type { GasFreeProvider } from "../../../application/ports/gasfree-provider.js"; +import type { + Config, + GasFreeAddressInfo, + GasFreeProviderConfig, + GasFreeState, + GasFreeTokenConfig, + GasFreeTransferRecord, + NetworkDescriptor, + SignedGasFreeAuthorization, +} from "../../../domain/types/index.js"; +import { + ChainError, + CliError, + TransportError, + UsageError, +} from "../../../domain/errors/index.js"; +import { TronAddress } from "../../../domain/address/index.js"; + +const MAX_BYTES = 1024 * 1024; +const ADDRESS = new TronAddress(); +const STATES = new Set([ + "WAITING", + "INPROGRESS", + "CONFIRMING", + "SUCCEED", + "FAILED", +]); +type Fetch = typeof globalThis.fetch; + +/** Java-compatible adapter for open.gasfree.io. Mutating POST requests are never retried. */ +export class GasFreeClient implements GasFreeProvider { + constructor( + private readonly config: Config, + private readonly timeoutMs: number, + private readonly fetchImpl: Fetch = globalThis.fetch, + private readonly clock: () => number = () => Date.now(), + ) {} + + async listTokens(network: NetworkDescriptor): Promise { + const data = object( + await this.#request(network, "GET", "/api/v1/config/token/all"), + "data", + ); + return array(data.tokens, "data.tokens").map((entry, index) => { + const item = object(entry, `data.tokens[${index}]`); + const symbol = optionalText(item.symbol, 32, `data.tokens[${index}].symbol`); + return { + tokenAddress: address(item.tokenAddress, `data.tokens[${index}].tokenAddress`), + activateFee: uint(item.activateFee, `data.tokens[${index}].activateFee`), + transferFee: uint(item.transferFee, `data.tokens[${index}].transferFee`), + ...(symbol === undefined ? {} : { symbol }), + ...(item.decimals === undefined + ? {} + : { decimals: smallUInt(item.decimals, `data.tokens[${index}].decimals`, 255) }), + }; + }); + } + + async listProviders(network: NetworkDescriptor): Promise { + const data = object( + await this.#request(network, "GET", "/api/v1/config/provider/all"), + "data", + ); + return array(data.providers, "data.providers").map((entry, index) => { + const item = object(entry, `data.providers[${index}]`); + const providerConfig = object(item.config, `data.providers[${index}].config`); + return { + address: address(item.address, `data.providers[${index}].address`), + defaultDeadlineDuration: uint( + providerConfig.defaultDeadlineDuration, + `data.providers[${index}].config.defaultDeadlineDuration`, + ), + ...(typeof item.default === "boolean" ? { isDefault: item.default } : {}), + }; + }); + } + + async getAddress( + network: NetworkDescriptor, + ownerAddress: string, + ): Promise { + if (!ADDRESS.validate(ownerAddress)) { + throw new UsageError("invalid_value", "ownerAddress must be a valid TRON address"); + } + const data = object( + await this.#request( + network, + "GET", + `/api/v1/address/${encodeURIComponent(ownerAddress)}`, + ), + "data", + ); + return { + ownerAddress, + gasFreeAddress: address(data.gasFreeAddress, "data.gasFreeAddress"), + active: boolean(data.active, "data.active"), + nonce: uint(data.nonce, "data.nonce"), + ...(typeof data.allowSubmit === "boolean" ? { allowSubmit: data.allowSubmit } : {}), + assets: array(data.assets, "data.assets").map((entry, index) => { + const item = object(entry, `data.assets[${index}]`); + return { + tokenAddress: address(item.tokenAddress, `data.assets[${index}].tokenAddress`), + activateFee: uint(item.activateFee, `data.assets[${index}].activateFee`), + transferFee: uint(item.transferFee, `data.assets[${index}].transferFee`), + }; + }), + }; + } + + async submitTransfer( + network: NetworkDescriptor, + authorization: SignedGasFreeAuthorization, + ): Promise { + const body = { + token: authorization.token, + serviceProvider: authorization.serviceProvider, + user: authorization.user, + receiver: authorization.receiver, + value: new LosslessNumber(authorization.value), + maxFee: new LosslessNumber(authorization.maxFee), + deadline: new LosslessNumber(authorization.deadline), + version: new LosslessNumber(authorization.version), + nonce: new LosslessNumber(authorization.nonce), + sig: authorization.sig, + }; + return transferRecord( + await this.#request(network, "POST", "/api/v1/gasfree/submit", body), + ); + } + + async trace( + network: NetworkDescriptor, + traceId: string, + ): Promise { + const normalized = traceIdentifier(traceId); + return transferRecord( + await this.#request( + network, + "GET", + `/api/v1/gasfree/${encodeURIComponent(normalized)}`, + ), + ); + } + + async #request( + network: NetworkDescriptor, + method: "GET" | "POST", + suffix: string, + body?: unknown, + ): Promise { + const metadata = endpoint(network); + const credentials = this.#credentials(); + const path = `${metadata.apiPrefix}${suffix}`; + const timestamp = String(Math.floor(this.clock() / 1000)); + const signature = createHmac("sha256", credentials.apiSecret) + .update(`${method}${path}${timestamp}`, "utf8") + .digest("base64"); + const encoded = body === undefined ? undefined : stringifyLosslessJson(body); + if (encoded !== undefined && Buffer.byteLength(encoded, "utf8") > MAX_BYTES) { + throw new UsageError("invalid_value", "GasFree request exceeds the 1 MiB limit"); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(`${metadata.baseUrl}${path}`, { + method, + headers: { + Timestamp: timestamp, + Authorization: `ApiKey ${credentials.apiKey}:${signature}`, + Accept: "application/json", + ...(encoded === undefined ? {} : { "content-type": "application/json" }), + }, + body: encoded, + signal: controller.signal, + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + }); + if (!response.ok) throw httpError(response); + const contentLength = response.headers.get("content-length"); + if ( + contentLength + && /^\d+$/.test(contentLength) + && Number(contentLength) > MAX_BYTES + ) { + throw new ChainError( + "provider_error", + "GasFree response exceeds the 1 MiB limit", + ); + } + const text = await readBoundedText(response, MAX_BYTES); + let decoded: unknown; + try { + decoded = normalizeLossless(parseLosslessJson(text)); + } catch { + throw new ChainError( + "provider_error", + "GasFree service returned malformed JSON", + ); + } + return unwrap(decoded); + } catch (error) { + if (error instanceof CliError) throw error; + if (controller.signal.aborted) { + throw new ChainError( + "timeout", + `GasFree request timed out after ${this.timeoutMs}ms`, + ); + } + throw new TransportError( + "provider_error", + "GasFree service request failed", + ); + } finally { + clearTimeout(timeout); + } + } + + #credentials(): { apiKey: string; apiSecret: string } { + const { gasfreeApiKey: apiKey, gasfreeApiSecret: apiSecret } = this.config; + if (!apiKey || !apiSecret) { + throw new UsageError( + "gasfree_credentials_missing", + "GasFree credentials are incomplete; configure gasfreeApiKey and gasfreeApiSecret", + ); + } + return { apiKey, apiSecret }; + } +} + +function endpoint( + network: NetworkDescriptor, +): { baseUrl: string; apiPrefix: string } { + const value = network.gasfree; + if (!value) { + throw new UsageError( + "unsupported_network", + `network ${network.id} does not support GasFree`, + ); + } + let url: URL; + try { + url = new URL(value.baseUrl); + } catch { + throw new UsageError("invalid_config", "GasFree baseUrl is invalid"); + } + if ( + url.protocol !== "https:" + || url.username + || url.password + || url.search + || url.hash + || url.pathname !== "/" + ) { + throw new UsageError( + "invalid_config", + "GasFree baseUrl must be an HTTPS origin without credentials, path, query, or fragment", + ); + } + if (!/^\/[a-z0-9-]+$/.test(value.apiPrefix)) { + throw new UsageError( + "invalid_config", + "GasFree apiPrefix must be one absolute path segment", + ); + } + return { baseUrl: url.origin, apiPrefix: value.apiPrefix }; +} + +function httpError(response: Response): CliError { + if (response.status === 401 || response.status === 403) { + return new ChainError( + "gasfree_auth_failed", + "GasFree credentials were rejected", + { status: response.status }, + ); + } + if (response.status === 404) { + return new ChainError("not_found", "GasFree resource was not found", { + status: 404, + }); + } + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return new ChainError( + "provider_rate_limited", + "GasFree service rate limit exceeded", + { + status: 429, + ...(retryAfter && /^[\x20-\x7e]{1,128}$/.test(retryAfter) + ? { retryAfter } + : {}), + }, + ); + } + if (response.status >= 500) { + return new TransportError( + "provider_error", + `GasFree service returned HTTP ${response.status}`, + ); + } + return new ChainError( + "gasfree_rejected", + `GasFree service returned HTTP ${response.status}`, + { status: response.status }, + ); +} + +function unwrap(value: unknown): unknown { + const root = object(value, "response"); + const code = smallUInt(root.code, "code", 999_999); + if (code !== 200) { + throw new ChainError( + "gasfree_rejected", + "GasFree service rejected the request", + { code }, + ); + } + if (root.data === null || root.data === undefined) { + throw new ChainError("not_found", "GasFree resource was not found"); + } + return root.data; +} + +function transferRecord(value: unknown): GasFreeTransferRecord { + const data = object(value, "data"); + const state = text(data.state, "data.state", 32) as GasFreeState; + if (!STATES.has(state)) throw invalid("data.state", "a known GasFree state"); + return { + id: traceIdentifier(text(data.id, "data.id", 128)), + state, + tokenAddress: address(data.tokenAddress, "data.tokenAddress"), + providerAddress: address(data.providerAddress, "data.providerAddress"), + accountAddress: address(data.accountAddress, "data.accountAddress"), + gasFreeAddress: address(data.gasFreeAddress, "data.gasFreeAddress"), + targetAddress: address(data.targetAddress, "data.targetAddress"), + amount: uint(data.amount, "data.amount"), + nonce: uint(data.nonce, "data.nonce"), + ...optionalUintFields(data, [ + "maxFee", + "expiredAt", + "estimatedTransferFee", + "estimatedActivateFee", + "estimatedTotalFee", + "estimatedTotalCost", + "txnAmount", + "txnTransferFee", + "txnActivateFee", + "txnTotalFee", + "txnTotalCost", + "txnBlockNum", + "txnBlockTimestamp", + ]), + ...(data.txnHash === undefined || data.txnHash === null + ? {} + : { txnHash: transactionHash(data.txnHash) }), + ...(data.txnState === undefined || data.txnState === null + ? {} + : { txnState: text(data.txnState, "data.txnState", 64) }), + ...(data.failureReason === undefined || data.failureReason === null + ? {} + : { + failureReason: text( + data.failureReason, + "data.failureReason", + 256, + ), + }), + }; +} + +function optionalUintFields( + data: Record, + fields: readonly string[], +): Record { + return Object.fromEntries( + fields + .filter((field) => data[field] !== undefined && data[field] !== null) + .map((field) => [field, uint(data[field], `data.${field}`)]), + ); +} + +function transactionHash(value: unknown): string { + const result = text(value, "data.txnHash", 66) + .replace(/^0x/, "") + .toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(result)) { + throw invalid("data.txnHash", "a 32-byte transaction hash"); + } + return result; +} + +function traceIdentifier(value: string): string { + const normalized = value.trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(normalized)) { + throw new UsageError( + "invalid_value", + "traceId contains unsupported characters", + ); + } + return normalized; +} + +function address(value: unknown, field: string): string { + const result = text(value, field, 64); + if (!ADDRESS.validate(result)) throw invalid(field, "a valid TRON address"); + return result; +} + +function boolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") throw invalid(field, "a boolean"); + return value; +} + +function uint(value: unknown, field: string): string { + let result: string; + if ( + typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0 + ) { + result = String(value); + } else if ( + typeof value === "string" + && /^(0|[1-9]\d*)$/.test(value) + ) { + result = value; + } else { + throw invalid(field, "an unsigned decimal integer"); + } + // All values entering GasFree authorization/hash math are uint256. Bound length before BigInt + // conversion so an authenticated-but-malicious response cannot trigger huge integer parsing. + if (result.length > 78 || BigInt(result) >= 1n << 256n) { + throw invalid(field, "an unsigned uint256 decimal integer"); + } + return result; +} + +function smallUInt(value: unknown, field: string, maximum: number): number { + const parsed = Number(uint(value, field)); + if (!Number.isSafeInteger(parsed) || parsed > maximum) { + throw invalid(field, `an integer no greater than ${maximum}`); + } + return parsed; +} + +function text(value: unknown, field: string, maximum: number): string { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > maximum + || /[\u0000-\u001f\u007f]/.test(value) + ) { + throw invalid( + field, + `non-empty text no longer than ${maximum} characters`, + ); + } + return value; +} + +function optionalText( + value: unknown, + maximum: number, + field: string, +): string | undefined { + return value === undefined || value === null + ? undefined + : text(value, field, maximum); +} + +function object(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw invalid(field, "an object"); + } + return value as Record; +} + +function array(value: unknown, field: string): unknown[] { + if (!Array.isArray(value) || value.length > 1000) { + throw invalid(field, "an array with at most 1000 entries"); + } + return value; +} + +function invalid(field: string, expected: string): ChainError { + return new ChainError( + "provider_error", + `GasFree ${field} must be ${expected}`, + ); +} + +async function readBoundedText( + response: Response, + maximum: number, +): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let output = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maximum) { + throw new ChainError( + "provider_error", + "GasFree response exceeds the 1 MiB limit", + ); + } + output += decoder.decode(value, { stream: true }); + } + return output + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +function normalizeLossless(value: unknown): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + const numeric = Number(exact); + return Number.isSafeInteger(numeric) ? numeric : exact; + } + if (Array.isArray(value)) return value.map(normalizeLossless); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + normalizeLossless(item), + ]), + ); + } + return value; +} diff --git a/ts/src/adapters/outbound/ledger/index.test.ts b/ts/src/adapters/outbound/ledger/index.test.ts index b939fc332..bcd7dcc8d 100644 --- a/ts/src/adapters/outbound/ledger/index.test.ts +++ b/ts/src/adapters/outbound/ledger/index.test.ts @@ -10,7 +10,7 @@ const { closeSpy, tip712Calls, failures } = vi.hoisted(() => ({ tip712Calls: [] as Array<{ path: string; domainHash: string; messageHash: string }>, failures: { tip712: undefined as Error | undefined, tip712Hang: false }, })); -vi.mock("@ledgerhq/hw-transport-node-hid", () => ({ +vi.mock("@ledgerhq/hw-transport-node-hid-noevents", () => ({ default: { open: async () => ({ close: closeSpy }) }, })); // Every device APDU never resolves — models an on-device prompt that is never tapped. diff --git a/ts/src/adapters/outbound/ledger/index.ts b/ts/src/adapters/outbound/ledger/index.ts index b0fa65d1f..5187eb7ce 100644 --- a/ts/src/adapters/outbound/ledger/index.ts +++ b/ts/src/adapters/outbound/ledger/index.ts @@ -4,7 +4,7 @@ * Transport is env-switchable so the same code path drives a real device or the Speculos * emulator (their only difference is the @ledgerhq transport; the hw-app-trx layer above is * identical). When SPECULOS_PORT is set we open the Speculos HTTP transport; otherwise we open - * the USB/HID transport. Both @ledgerhq deps are imported lazily so the Speculos path works + * the HID transport. Both @ledgerhq deps are imported lazily so the Speculos path works * without node-hid's native build present, and so unit tests that mock these methods never load * a native module. * @@ -61,7 +61,10 @@ async function openTransport(): Promise<{ transport: unknown; close: () => Promi }); return { transport, close: () => transport.close() }; } - const Hid = unwrap(await import("@ledgerhq/hw-transport-node-hid")); + // The CLI opens one device for one operation and never subscribes to hot-plug events. The + // no-events transport avoids loading the separate `usb` native addon, which is both unnecessary + // here and would otherwise double the native surface of standalone release binaries. + const Hid = unwrap(await import("@ledgerhq/hw-transport-node-hid-noevents")); const transport = await Hid.open(""); return { transport, close: () => transport.close() }; } diff --git a/ts/src/adapters/outbound/persistence/fs/fs.test.ts b/ts/src/adapters/outbound/persistence/fs/fs.test.ts index f6fda4e2e..6aab85d90 100644 --- a/ts/src/adapters/outbound/persistence/fs/fs.test.ts +++ b/ts/src/adapters/outbound/persistence/fs/fs.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from "vitest"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { + existsSync, + fstatSync, + ftruncateSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AtomicFileStore } from "./index.js"; @@ -202,6 +211,37 @@ describe("AtomicFileStore.writeJsonAll", () => { }); }); +describe("AtomicFileStore single-file writes", () => { + it("opens the fsync handle with write access required by Windows FlushFileBuffers", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const path = join(root, "wallets.json"); + writeFileSync(path, "{}\n"); + + const store = new AtomicFileStore(); + store.rawFsyncFile = (fd: number) => { + // ftruncate-to-current-size is non-destructive but requires a write-capable descriptor. + // This fails with the former openSync(path, "r") implementation on every platform. + ftruncateSync(fd, fstatSync(fd).size); + }; + + expect(() => store.fsyncFile(path)).not.toThrow(); + expect(readFileSync(path, "utf8")).toBe("{}\n"); + }); + + it("removes its temporary file when fsync fails before publication", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const path = join(root, "wallets.json"); + const store = new AtomicFileStore(); + store.rawFsyncFile = () => { + throw Object.assign(new Error("EPERM: operation not permitted, fsync"), { code: "EPERM" }); + }; + + expect(() => store.writeJson(path, { version: 1 })).toThrowError(/EPERM/); + expect(existsSync(path)).toBe(false); + expect(readdirSync(root).filter((file) => file.endsWith(".tmp"))).toEqual([]); + }); +}); + describe("AtomicFileStore.fsyncDir", () => { it("propagates a real I/O error instead of silently reporting a durable write", () => { const store = new AtomicFileStore(); diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts index 97dc84556..0cd802012 100644 --- a/ts/src/adapters/outbound/persistence/fs/index.ts +++ b/ts/src/adapters/outbound/persistence/fs/index.ts @@ -51,12 +51,7 @@ export class AtomicFileStore { } writeJson(path: string, value: unknown): void { - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; - writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); - this.fsyncFile(tmp); // durably land the content before the rename that publishes it - renameSync(tmp, path); // atomic replace on same filesystem - this.fsyncDir(dirname(path)); // durably land the rename (the directory entry) + this.#writeAtomically(path, JSON.stringify(value, null, 2) + "\n"); } /** transactional multi-file write: stage every temp first, then commit each into place while @@ -149,10 +144,16 @@ export class AtomicFileStore { renameSync(from, to); } - /** fsync a file's bytes to stable storage. Separate seam so tests can observe the barrier. */ + /** Raw file fsync syscall — overridable seam so tests can assert handle capabilities. */ + rawFsyncFile(fd: number): void { + fsyncSync(fd); + } + + /** fsync a file's bytes to stable storage. Windows FlushFileBuffers requires a handle opened + * with GENERIC_WRITE, so use r+ (read/write, no truncation) rather than a read-only r handle. */ fsyncFile(path: string): void { - const fd = openSync(path, "r"); - try { fsyncSync(fd); } finally { closeSync(fd); } + const fd = openSync(path, "r+"); + try { this.rawFsyncFile(fd); } finally { closeSync(fd); } } /** raw directory fsync syscall — overridable seam so tests can inject a failure. */ @@ -178,12 +179,23 @@ export class AtomicFileStore { } writeText(path: string, text: string): void { + this.#writeAtomically(path, text); + } + + #writeAtomically(path: string, text: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; - writeFileSync(tmp, text, { mode: 0o600 }); - this.fsyncFile(tmp); - renameSync(tmp, path); - this.fsyncDir(dirname(path)); + try { + writeFileSync(tmp, text, { mode: 0o600 }); + this.fsyncFile(tmp); + renameSync(tmp, path); + this.fsyncDir(dirname(path)); + } catch (e) { + // A failed fsync/rename must not leave a plausible-looking keystore cache behind. Only remove + // this invocation's high-specificity temp path; an already-published target is never touched. + try { unlinkSync(tmp); } catch { /* absent after a successful rename, otherwise best-effort */ } + throw e; + } } withLock(path: string, fn: () => T, opts: { timeoutMs?: number; staleMs?: number } = {}): T { diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.test.ts b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts new file mode 100644 index 000000000..5045cea46 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SecureKeypairWriter } from "./keypair-writer.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function root(): string { + const value = mkdtempSync(join(tmpdir(), "wallet-cli-keypair-")); + roots.push(value); + return value; +} + +describe("SecureKeypairWriter", () => { + it("creates a new durable 0600 JSON artifact", () => { + const directory = root(); + const path = join(directory, "nested", "key.json"); + expect(new SecureKeypairWriter().write(path, { privateKey: "secret" })) + .toBe(path); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + privateKey: "secret", + }); + if (process.platform !== "win32") { + expect(lstatSync(path).mode & 0o777).toBe(0o600); + } + }); + + it("never overwrites an existing file", () => { + const directory = root(); + const path = join(directory, "key.json"); + writeFileSync(path, "original", { mode: 0o600 }); + + expect(() => + new SecureKeypairWriter().write(path, { privateKey: "replacement" }) + ).toThrow(/refusing to overwrite/); + expect(readFileSync(path, "utf8")).toBe("original"); + }); + + it("never follows an existing final-path symlink", () => { + const directory = root(); + const external = join(directory, "external.json"); + const path = join(directory, "key.json"); + writeFileSync(external, "original", { mode: 0o600 }); + symlinkSync(external, path); + + expect(() => + new SecureKeypairWriter().write(path, { privateKey: "replacement" }) + ).toThrow(/refusing to overwrite/); + expect(readFileSync(external, "utf8")).toBe("original"); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.ts b/ts/src/adapters/outbound/persistence/keypair-writer.ts new file mode 100644 index 000000000..2bfa38c69 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/keypair-writer.ts @@ -0,0 +1,62 @@ +import { + closeSync, + constants, + fchmodSync, + fsyncSync, + mkdirSync, + openSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { ExecutionError } from "../../../domain/errors/index.js"; +import type { KeypairWriter } from "../../../application/ports/keypair-writer.js"; + +/** Exclusive, no-follow writer for plaintext private-key artifacts. */ +export class SecureKeypairWriter implements KeypairWriter { + write(path: string, value: unknown): string { + const target = resolve(path); + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + let descriptor: number | undefined; + try { + descriptor = openSync( + target, + constants.O_WRONLY + | constants.O_CREAT + | constants.O_EXCL + | (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + fchmodSync(descriptor, 0o600); + writeFileSync( + descriptor, + `${JSON.stringify(value, null, 2)}\n`, + "utf8", + ); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + if (process.platform !== "win32") { + const parent = openSync(dirname(target), constants.O_RDONLY); + try { + fsyncSync(parent); + } finally { + closeSync(parent); + } + } + return target; + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ELOOP") { + throw new ExecutionError( + "file_exists", + `refusing to overwrite keypair file: ${target}`, + ); + } + throw new ExecutionError( + "io_error", + `could not write keypair file: ${target}`, + ); + } + } +} diff --git a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts new file mode 100644 index 000000000..412022f33 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { lstatSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { TransactionArtifactWriter } from "./transaction-artifact-writer.js"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("TransactionArtifactWriter", () => { + it("atomically writes a newline-terminated 0644 artifact", () => { + const dir = mkdtempSync(join(tmpdir(), "wallet-cli-tx-")); + dirs.push(dir); + const path = join(dir, "signed.hex"); + new TransactionArtifactWriter().write(path, "abcd"); + expect(readFileSync(path, "utf8")).toBe("abcd\n"); + if (process.platform !== "win32") expect(lstatSync(path).mode & 0o777).toBe(0o644); + }); + + it("refuses to replace a symbolic link", () => { + const dir = mkdtempSync(join(tmpdir(), "wallet-cli-tx-")); + dirs.push(dir); + const target = join(dir, "target"); + const link = join(dir, "signed.hex"); + symlinkSync(target, link); + expect(() => new TransactionArtifactWriter().write(link, "abcd")).toThrowError(/symbolic-link/); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts new file mode 100644 index 000000000..e484253dc --- /dev/null +++ b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts @@ -0,0 +1,60 @@ +import { + closeSync, + constants, + fchmodSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { randomBytes } from "node:crypto"; +import { dirname } from "node:path"; +import { ExecutionError } from "../../../domain/errors/index.js"; + +/** Atomic, non-secret transaction artifact writer. Published files are mode 0644. */ +export class TransactionArtifactWriter { + write(path: string, hex: string): void { + if (!path) throw new ExecutionError("io_error", "transaction artifact path is empty"); + try { + const current = lstatSync(path, { throwIfNoEntry: false }); + if (current?.isSymbolicLink()) { + throw new ExecutionError("io_error", "refusing to replace a symbolic-link transaction artifact"); + } + const dir = dirname(path); + mkdirSync(dir, { recursive: true }); + const tmp = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + let fd: number | undefined; + try { + fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644); + fchmodSync(fd, 0o644); + writeFileSync(fd, `${hex}\n`, "utf8"); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + renameSync(tmp, path); + if (process.platform !== "win32") { + const dirFd = openSync(dir, constants.O_RDONLY); + try { + fsyncSync(dirFd); + } finally { + closeSync(dirFd); + } + } + } catch (error) { + if (fd !== undefined) closeSync(fd); + try { + unlinkSync(tmp); + } catch { + // best-effort cleanup + } + throw error; + } + } catch (error) { + if (error instanceof ExecutionError) throw error; + throw new ExecutionError("io_error", `could not write transaction artifact: ${(error as Error).message}`); + } + } +} diff --git a/ts/src/adapters/outbound/qr/index.test.ts b/ts/src/adapters/outbound/qr/index.test.ts new file mode 100644 index 000000000..72bd93259 --- /dev/null +++ b/ts/src/adapters/outbound/qr/index.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { TerminalQrEncoder } from "./index.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; + +describe("TerminalQrEncoder", () => { + it("renders a complete fixed-width ANSI-free matrix on a wide TTY", () => { + const output = new TerminalQrEncoder({ + isTTY: true, + columns: 120, + }).encode(ADDRESS); + const lines = output!.split("\n"); + + expect(output).toContain("█"); + expect(output).not.toMatch(/\u001b/); + expect(lines.length).toBeGreaterThan(10); + expect(new Set(lines.map((line) => line.length))).toHaveLength(1); + expect(lines[0]!.trim()).toBe(""); + expect(lines.at(-1)!.trim()).toBe(""); + }); + + it("encodes the exact payload rather than returning a static symbol", () => { + const terminal = { isTTY: true, columns: 120 }; + const first = new TerminalQrEncoder(terminal).encode(ADDRESS); + const second = new TerminalQrEncoder(terminal).encode( + "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + ); + expect(first).not.toBe(second); + }); + + it("degrades for non-TTY and narrow terminals", () => { + expect(new TerminalQrEncoder({ + isTTY: false, + columns: 120, + }).encode(ADDRESS)).toBeNull(); + expect(new TerminalQrEncoder({ + isTTY: true, + columns: 10, + }).encode(ADDRESS)).toBeNull(); + }); + + it("rejects unsafe payload text before encoding", () => { + expect(() => + new TerminalQrEncoder({ + isTTY: true, + columns: 120, + }).encode("address\u202e") + ).toThrow(/safe characters/); + }); +}); diff --git a/ts/src/adapters/outbound/qr/index.ts b/ts/src/adapters/outbound/qr/index.ts new file mode 100644 index 000000000..0d52cb31f --- /dev/null +++ b/ts/src/adapters/outbound/qr/index.ts @@ -0,0 +1,69 @@ +import QRCode from "qrcode"; +import type { QrEncoder } from "../../../application/ports/qr-encoder.js"; +import { UsageError } from "../../../domain/errors/index.js"; + +interface TerminalShape { + isTTY?: boolean; + columns?: number; +} + +/** Compact ANSI-free half-block QR renderer with a four-module quiet zone. */ +export class TerminalQrEncoder implements QrEncoder { + constructor( + private readonly terminal: TerminalShape = process.stdout, + ) {} + + encode(payload: string): string | null { + if ( + payload.length === 0 + || payload.length > 512 + || /[\p{Cc}\p{Cf}]/u.test(payload) + ) { + throw new UsageError( + "invalid_value", + "QR payload must contain 1-512 safe characters", + ); + } + if (!this.terminal.isTTY) return null; + + let matrix: ReturnType["modules"]; + try { + matrix = QRCode.create(payload, { + errorCorrectionLevel: "M", + }).modules; + } catch { + throw new UsageError( + "invalid_value", + "could not encode the receive address as a QR code", + ); + } + const quietZone = 4; + const width = matrix.size + quietZone * 2; + if ((this.terminal.columns ?? 0) < width) return null; + + const bit = (row: number, column: number): boolean => + row >= quietZone + && row < matrix.size + quietZone + && column >= quietZone + && column < matrix.size + quietZone + && matrix.get( + row - quietZone, + column - quietZone, + ) === 1; + const lines: string[] = []; + for (let row = 0; row < width; row += 2) { + let line = ""; + for (let column = 0; column < width; column += 1) { + const top = bit(row, column); + const bottom = bit(row + 1, column); + line += + top + ? bottom ? "█" : "▀" + : bottom ? "▄" : " "; + } + // Retain the right quiet zone; it is part of the scannable symbol. + lines.push(line); + } + return lines.join("\n"); + } +} diff --git a/ts/src/adapters/outbound/tokenbook/builtins.ts b/ts/src/adapters/outbound/tokenbook/builtins.ts index 792c3c1d3..9919e88dd 100644 --- a/ts/src/adapters/outbound/tokenbook/builtins.ts +++ b/ts/src/adapters/outbound/tokenbook/builtins.ts @@ -9,7 +9,11 @@ export const OFFICIAL_TOKENS: Record = { "tron:mainnet": [ { kind: "trc20", id: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", symbol: "USDT", decimals: 6, name: "Tether USD" }, { kind: "trc20", id: "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", symbol: "USDC", decimals: 6, name: "USD Coin" }, + { kind: "trc20", id: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", symbol: "USDD", decimals: 18, name: "Usdd Stablecoin" }, + ], + "tron:nile": [ + { kind: "trc20", id: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", symbol: "USDT", decimals: 6, name: "Tether USD" }, + { kind: "trc20", id: "TYQF9cAeJ3Faq8QXpHxTcFco72DRCQbgFt", symbol: "USDD", decimals: 18, name: "Usdd Stablecoin" }, ], - "tron:nile": [], "tron:shasta": [], }; diff --git a/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts b/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts index 9e4389c6f..422edc3a6 100644 --- a/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts +++ b/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts @@ -7,6 +7,10 @@ import { AtomicFileStore } from "../persistence/fs/index.js"; import type { TokenEntry } from "../../../domain/types/index.js"; const USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; // official mainnet +const NILE_OFFICIAL: TokenEntry[] = [ + { kind: "trc20", id: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", symbol: "USDT", decimals: 6, name: "Tether USD" }, + { kind: "trc20", id: "TYQF9cAeJ3Faq8QXpHxTcFco72DRCQbgFt", symbol: "USDD", decimals: 18, name: "Usdd Stablecoin" }, +]; const REF = "wlt_abc.0"; const customToken: TokenEntry = { kind: "trc20", id: "TCustomContractXXXXXXXXXXXXXXXXXXXX", symbol: "CUS", decimals: 8, name: "Custom" }; @@ -30,18 +34,23 @@ describe("TokenBook", () => { tb = freshBook(); }); - it("official layer ships USDT/USDC on mainnet, empty on nile", () => { - expect(tb.official("tron:mainnet").map((t) => t.symbol)).toEqual(["USDT", "USDC"]); - expect(tb.official("tron:nile")).toEqual([]); + it("official layer ships stablecoins on mainnet/nile, empty on shasta", () => { + expect(tb.official("tron:mainnet").map((t) => t.symbol)).toEqual(["USDT", "USDC", "USDD"]); + expect(tb.official("tron:nile")).toEqual(NILE_OFFICIAL); + expect(tb.official("tron:shasta")).toEqual([]); }); it("add() appends to the user layer; effective() unions official-first + tags source", () => { expect(tb.add("tron:nile", REF, customToken)).toBe("added"); const eff = tb.effective("tron:nile", REF); - expect(eff).toEqual([{ ...customToken, source: "user" }]); + expect(eff).toEqual([ + ...NILE_OFFICIAL.map((t) => ({ ...t, source: "official" })), + { ...customToken, source: "user" }, + ]); const onMainnet = tb.effective("tron:mainnet", REF); - expect(onMainnet.map((t) => `${t.source}:${t.symbol}`)).toEqual(["official:USDT", "official:USDC"]); + expect(onMainnet.map((t) => `${t.source}:${t.symbol}`)) + .toEqual(["official:USDT", "official:USDC", "official:USDD"]); }); it("add() of an official token → token_already_listed", () => { @@ -52,7 +61,7 @@ describe("TokenBook", () => { tb.add("tron:nile", REF, customToken); const action = tb.add("tron:nile", REF, { ...customToken, symbol: "NEW", decimals: 2 }); expect(action).toBe("refreshed"); - const eff = tb.effective("tron:nile", REF); + const eff = tb.effective("tron:nile", REF).filter((t) => t.source === "user"); expect(eff).toHaveLength(1); expect(eff[0]).toMatchObject({ symbol: "NEW", decimals: 2, source: "user" }); }); diff --git a/ts/src/adapters/outbound/tronlink/auth.test.ts b/ts/src/adapters/outbound/tronlink/auth.test.ts new file mode 100644 index 000000000..3d1d3c14c --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/auth.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalTronLinkQuery, + encodeTronLinkQuery, + signTronLinkRequest, + tronLinkAuthParameters, +} from "./auth.js"; + +describe("official TronLink multi-sign HMAC protocol", () => { + it("matches the Java sorted-parameter signing fixture", () => { + const auth = tronLinkAuthParameters( + { secretId: "sid-fixture", secretKey: "key-fixture", channel: "wallet-cli" }, + () => 1_900_000_000_000, + () => "00000000-0000-4000-8000-000000000001", + ); + const parameters = { ...auth, address: "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7" }; + expect(canonicalTronLinkQuery(parameters)).toBe( + "address=TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7&channel=wallet-cli&secret_id=sid-fixture&sign_version=v1&ts=1900000000000&uuid=00000000-0000-4000-8000-000000000001", + ); + expect(signTronLinkRequest("get", "/multi/list", parameters, "key-fixture")).toEqual({ + canonical: "GET/multi/list?address=TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7&channel=wallet-cli&secret_id=sid-fixture&sign_version=v1&ts=1900000000000&uuid=00000000-0000-4000-8000-000000000001", + signature: "+Gs+Mg1+QpaPCd6/LjVDdvfcyCC7MOYpb+G1Z2QFqwU=", + }); + }); + + it("URL-encodes only after signing using java.net.URLEncoder rules", () => { + expect(encodeTronLinkQuery({ sign: "+/=" })).toBe("sign=%2B%2F%3D"); + expect(encodeTronLinkQuery({ value: "a b~c" })).toBe("value=a+b%7Ec"); + }); +}); diff --git a/ts/src/adapters/outbound/tronlink/auth.ts b/ts/src/adapters/outbound/tronlink/auth.ts new file mode 100644 index 000000000..cb67d2a9a --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/auth.ts @@ -0,0 +1,64 @@ +import { createHmac } from "node:crypto"; +import { ChainError } from "../../../domain/errors/index.js"; + +export interface TronLinkCredentials { + secretId: string; + secretKey: string; + channel: string; +} + +export function canonicalTronLinkQuery(parameters: Readonly>): string { + const entries = Object.entries(parameters); + if (entries.length === 0) { + throw new ChainError("provider_error", "TronLink signature parameters are empty"); + } + return entries + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + +/** Canonical form used by Java MultiSignService; values are signed before URL encoding. */ +export function signTronLinkRequest( + method: string, + path: string, + parameters: Readonly>, + secretKey: string, +): { canonical: string; signature: string } { + if (!secretKey) throw new ChainError("provider_error", "TronLink multi-sign secret key is not configured"); + if (!/^\/[a-z0-9/_-]+$/i.test(path)) { + throw new ChainError("provider_error", "invalid TronLink request path"); + } + const canonical = `${method.toUpperCase()}${path}?${canonicalTronLinkQuery(parameters)}`; + return { + canonical, + signature: createHmac("sha256", secretKey).update(canonical, "utf8").digest("base64"), + }; +} + +export function tronLinkAuthParameters( + credentials: TronLinkCredentials, + clock: () => number, + uuid: () => string, +): Record { + return { + sign_version: "v1", + channel: credentials.channel, + secret_id: credentials.secretId, + ts: String(clock()), + uuid: uuid(), + }; +} + +export function encodeTronLinkQuery(parameters: Readonly>): string { + return Object.entries(parameters) + .map(([key, value]) => `${javaUrlEncode(key)}=${javaUrlEncode(value)}`) + .join("&"); +} + +/** java.net.URLEncoder compatibility: form encoding uses '+' for spaces and escapes '~'. */ +function javaUrlEncode(value: string): string { + return encodeURIComponent(value) + .replace(/[!'()~]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(/%20/g, "+"); +} diff --git a/ts/src/adapters/outbound/tronlink/client.test.ts b/ts/src/adapters/outbound/tronlink/client.test.ts new file mode 100644 index 000000000..2231d0081 --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/client.test.ts @@ -0,0 +1,147 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { Config, NetworkDescriptor, TronTransactionArtifact } from "../../../domain/types/index.js"; +import { signTronLinkRequest } from "./auth.js"; +import { TronLinkClient } from "./client.js"; + +const ADDRESS = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const UUID = "00000000-0000-4000-8000-000000000001"; +const NOW = 1_900_000_000_000; +const CONFIG = { + tronlinkSecretId: "sid", + tronlinkSecretKey: "super-secret", + tronlinkChannel: "wallet-cli", +} as Config; +const NETWORK = { + id: "tron:mainnet", + family: "tron", + chainId: "mainnet", + tronlinkHttpEndpoint: "https://api.walletadapter.org", +} as NetworkDescriptor; + +function response(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); +} + +class FakeSocket extends EventEmitter { + readyState = 0; + sent: string[] = []; + send(data: string) { this.sent.push(data); } + close(code = 1000) { + this.readyState = 3; + this.emit("close", code); + } + terminate() { + this.readyState = 3; + this.emit("close", 1006); + } + open() { + this.readyState = 1; + this.emit("open"); + } +} + +describe("TronLink REST/WebSocket collaboration adapter", () => { + it("matches Java GET signing while leaving filters outside the signed set", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 0, data: { range_total: 0, data: [] } })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + await client.list(NETWORK, ADDRESS, { state: 0, isSigned: false, start: 20, limit: 10 }); + + const calledUrl = new URL(String(fetchMock.mock.calls[0]![0])); + expect(calledUrl.pathname).toBe("/multi/list"); + expect(calledUrl.searchParams.get("state")).toBe("0"); + expect(calledUrl.searchParams.get("is_sign")).toBe("false"); + expect(calledUrl.searchParams.get("sign")).toBe( + signTronLinkRequest("GET", "/multi/list", { + sign_version: "v1", + channel: "wallet-cli", + secret_id: "sid", + ts: String(NOW), + uuid: UUID, + address: ADDRESS, + }, "super-secret").signature, + ); + }); + + it("uploads unsigned raw_data with the Java createTransaction query shape", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 0, data: {} })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + await client.create(NETWORK, ADDRESS, { + permissionName: "finance", + txId: "ab".repeat(32), + rawDataJson: '{"contract":[]}', + contractType: "TransferContract", + }); + + const [rawUrl, init] = fetchMock.mock.calls[0]!; + const calledUrl = new URL(String(rawUrl)); + expect(calledUrl.searchParams.get("permission_name")).toBe("finance"); + expect(calledUrl.searchParams.get("tx_id")).toBe("ab".repeat(32)); + expect(JSON.parse(String(init?.body))).toEqual({ + raw_data: '{"contract":[]}', + extra: { type: "TransferContract" }, + }); + }); + + it("submits the complete accumulated transaction for a signing step", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 0, data: {} })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + const transaction = { + visible: true, + txID: "ab".repeat(32), + raw_data: { contract: [] }, + raw_data_hex: "00", + } as TronTransactionArtifact; + await client.submit(NETWORK, ADDRESS, transaction); + expect(JSON.parse(String(fetchMock.mock.calls[0]![1]?.body))).toEqual({ address: ADDRESS, transaction }); + }); + + it("uses the official /multi/socket path and sends the v1 address subscription", async () => { + const socket = new FakeSocket(); + let connectedUrl: URL | undefined; + const factory = (url: URL) => { + connectedUrl = url; + queueMicrotask(() => socket.open()); + return socket as never; + }; + const controller = new AbortController(); + const messages: unknown[] = []; + const client = new TronLinkClient( + CONFIG, + 1_000, + globalThis.fetch, + () => NOW, + () => UUID, + factory, + ); + const watching = client.watch(NETWORK, ADDRESS, controller.signal, (message) => messages.push(message)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(connectedUrl?.pathname).toBe("/multi/socket"); + expect(connectedUrl?.searchParams.get("address")).toBe(ADDRESS); + expect(socket.sent).toEqual([JSON.stringify({ address: ADDRESS, version: "v1" })]); + + socket.emit("message", Buffer.from('[{"state":0,"is_sign":0}]')); + controller.abort(); + await watching; + expect(messages).toEqual([[{ state: 0, is_sign: 0 }]]); + }); + + it("requires complete credentials and rejects insecure endpoints before a request", async () => { + const fetchMock = vi.fn(); + await expect(new TronLinkClient({} as Config, 1_000, fetchMock as typeof fetch) + .list(NETWORK, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.toMatchObject({ code: "tronlink_credentials_missing" }); + + const insecure = { ...NETWORK, tronlinkHttpEndpoint: "http://api.walletadapter.org" }; + await expect(new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch) + .list(insecure, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.toMatchObject({ code: "invalid_config" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/outbound/tronlink/client.ts b/ts/src/adapters/outbound/tronlink/client.ts new file mode 100644 index 000000000..33f4e3e00 --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/client.ts @@ -0,0 +1,366 @@ +import { randomUUID } from "node:crypto"; +import WebSocket, { type ClientOptions, type RawData } from "ws"; +import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; +import type { + TronLinkCollaborationPort, + TronLinkCreateRequest, + TronLinkListFilter, + TronLinkRemotePage, + TronLinkRemoteRecord, +} from "../../../application/ports/tronlink-collaboration.js"; +import type { + Config, + NetworkDescriptor, + TronTransactionArtifact, +} from "../../../domain/types/index.js"; +import { CliError, ChainError, TransportError, UsageError } from "../../../domain/errors/index.js"; +import { + encodeTronLinkQuery, + signTronLinkRequest, + tronLinkAuthParameters, + type TronLinkCredentials, +} from "./auth.js"; + +const MAX_RESPONSE_BYTES = 1024 * 1024; +const MAX_REQUEST_BYTES = 1024 * 1024; +const SOCKET_PATH = "/multi/socket"; + +type Fetch = typeof globalThis.fetch; +type SocketOptions = ClientOptions; + +interface SocketLike { + readonly readyState: number; + once(event: "open", listener: () => void): this; + once(event: "error", listener: (error: Error) => void): this; + once(event: "close", listener: (code: number) => void): this; + on(event: "message", listener: (data: RawData) => void): this; + send(data: string): void; + close(code?: number, reason?: string): void; + terminate(): void; +} + +type SocketFactory = (url: URL, options: SocketOptions) => SocketLike; + +/** Bounded Java-compatible adapter for walletadapter.org's multi-sign REST/WebSocket protocol. */ +export class TronLinkClient implements TronLinkCollaborationPort { + constructor( + private readonly config: Config, + private readonly timeoutMs: number, + private readonly fetchImpl: Fetch = globalThis.fetch, + private readonly clock: () => number = () => Date.now(), + private readonly uuid: () => string = randomUUID, + private readonly socketFactory: SocketFactory = (url, options) => new WebSocket(url, options), + ) {} + + async list( + network: NetworkDescriptor, + address: string, + filter: TronLinkListFilter, + ): Promise { + const credentials = this.#credentials(); + const path = "/multi/list"; + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + // Java signs only auth + address for GET; filter and pagination fields are unsigned by protocol. + const signed = { ...auth, address }; + const sign = signTronLinkRequest("GET", path, signed, credentials.secretKey).signature; + const parameters: Record = { + address, + start: String(filter.start), + limit: String(filter.limit), + state: String(filter.state), + ...(filter.isSigned === undefined ? {} : { is_sign: String(filter.isSigned) }), + ...auth, + sign, + }; + return parsePage(await this.#request(network, path, parameters), filter.limit); + } + + async create( + network: NetworkDescriptor, + address: string, + request: TronLinkCreateRequest, + ): Promise { + const credentials = this.#credentials(); + const path = "/multi/transaction"; + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + const signed = { + ...auth, + address, + permission_name: request.permissionName, + tx_id: request.txId, + }; + const sign = signTronLinkRequest("POST", path, signed, credentials.secretKey).signature; + await this.#request(network, path, { ...signed, sign }, { + raw_data: request.rawDataJson, + extra: { type: request.contractType }, + }); + } + + async submit( + network: NetworkDescriptor, + address: string, + transaction: TronTransactionArtifact, + ): Promise { + const credentials = this.#credentials(); + const path = "/multi/transaction"; + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + const signed = { ...auth, address }; + const sign = signTronLinkRequest("POST", path, signed, credentials.secretKey).signature; + await this.#request(network, path, { ...signed, sign }, { address, transaction }); + } + + async watch( + network: NetworkDescriptor, + address: string, + signal: AbortSignal, + onMessage: (payload: unknown) => void, + ): Promise { + const credentials = this.#credentials(); + const endpoint = tronLinkEndpoint(network); + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + const signed = { ...auth, address }; + const sign = signTronLinkRequest("GET", SOCKET_PATH, signed, credentials.secretKey).signature; + const socketUrl = new URL(`${endpoint}${SOCKET_PATH}`); + socketUrl.protocol = "wss:"; + socketUrl.search = encodeTronLinkQuery({ ...signed, sign }); + + await new Promise((resolve, reject) => { + let opened = false; + let finished = false; + const socket = this.socketFactory(socketUrl, { + handshakeTimeout: this.timeoutMs, + maxPayload: MAX_RESPONSE_BYTES, + perMessageDeflate: false, + followRedirects: false, + }); + + const finish = (error?: CliError) => { + if (finished) return; + finished = true; + signal.removeEventListener("abort", abort); + if (error) reject(error); + else resolve(); + }; + const abort = () => { + if (socket.readyState === WebSocket.OPEN) socket.close(1000, "client shutdown"); + else socket.terminate(); + finish(); + }; + + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) return abort(); + + socket.once("open", () => { + opened = true; + socket.send(JSON.stringify({ address, version: "v1" })); + }); + socket.on("message", (data: RawData) => { + const bytes = rawDataBytes(data); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + socket.close(1009, "message too large"); + return finish(new ChainError("provider_error", "TronLink WebSocket message exceeds 1 MiB")); + } + try { + onMessage(normalizeLossless(parseLosslessJson(bytes.toString("utf8")))); + } catch { + // The Java client ignores non-JSON frames. Keep the connection alive. + } + }); + socket.once("error", () => { + finish(new TransportError("provider_error", "TronLink WebSocket connection failed")); + }); + socket.once("close", (code) => { + if (signal.aborted || code === 1000) return finish(); + finish(new TransportError( + "provider_error", + opened + ? `TronLink WebSocket closed unexpectedly (code ${code})` + : "TronLink WebSocket handshake failed", + )); + }); + }); + } + + #credentials(): TronLinkCredentials { + const { tronlinkSecretId, tronlinkSecretKey, tronlinkChannel } = this.config; + if (!tronlinkSecretId || !tronlinkSecretKey || !tronlinkChannel) { + throw new UsageError( + "tronlink_credentials_missing", + "TronLink credentials are incomplete; configure tronlinkSecretId, tronlinkSecretKey, and tronlinkChannel", + ); + } + return { + secretId: tronlinkSecretId, + secretKey: tronlinkSecretKey, + channel: tronlinkChannel, + }; + } + + async #request( + network: NetworkDescriptor, + path: string, + parameters: Readonly>, + body?: unknown, + ): Promise { + const endpoint = tronLinkEndpoint(network); + const url = `${endpoint}${path}?${encodeTronLinkQuery(parameters)}`; + const encodedBody = body === undefined ? undefined : JSON.stringify(body); + if (encodedBody !== undefined && Buffer.byteLength(encodedBody, "utf8") > MAX_REQUEST_BYTES) { + throw new UsageError("invalid_value", "TronLink request body exceeds the 1 MiB limit"); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(url, { + method: body === undefined ? "GET" : "POST", + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: encodedBody, + signal: controller.signal, + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + }); + if (!response.ok) throw httpError(response); + const contentLength = response.headers.get("content-length"); + if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > MAX_RESPONSE_BYTES) { + throw new ChainError("provider_error", "TronLink response exceeds the 1 MiB limit"); + } + const text = await readBoundedText(response, MAX_RESPONSE_BYTES); + let root: unknown; + try { + root = normalizeLossless(parseLosslessJson(text)); + } catch { + throw new ChainError("provider_error", "TronLink collaboration service returned malformed JSON"); + } + return unwrapBusinessResponse(root); + } catch (error) { + if (error instanceof CliError) throw error; + if (controller.signal.aborted) { + throw new ChainError("timeout", `TronLink request timed out after ${this.timeoutMs}ms`); + } + throw new TransportError("provider_error", "TronLink collaboration service request failed"); + } finally { + clearTimeout(timeout); + } + } +} + +function httpError(response: Response): CliError { + if (response.status === 404) { + return new ChainError("not_found", "TronLink collaboration resource was not found"); + } + if (response.status === 429) { + return new ChainError("provider_error", "TronLink collaboration service rate limit exceeded", { + status: 429, + }); + } + return new TransportError("provider_error", `TronLink collaboration service returned HTTP ${response.status}`, { + status: response.status, + }); +} + +function tronLinkEndpoint(network: NetworkDescriptor): string { + if (!network.tronlinkHttpEndpoint) { + throw new UsageError("unsupported_network", `network ${network.id} has no TronLink collaboration endpoint`); + } + let parsed: URL; + try { + parsed = new URL(network.tronlinkHttpEndpoint); + } catch { + throw new UsageError("invalid_config", `network ${network.id} has an invalid TronLink endpoint`); + } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new UsageError("invalid_config", "TronLink endpoint must be HTTPS without credentials, query, or fragment"); + } + return parsed.toString().replace(/\/+$/, ""); +} + +async function readBoundedText(response: Response, maximum: number): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maximum) throw new ChainError("provider_error", "TronLink response exceeds the 1 MiB limit"); + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +function unwrapBusinessResponse(value: unknown): unknown { + const root = record(value, "response"); + const code = safeInteger(root.code, "code", 0); + if (code !== 0) { + throw new ChainError("provider_error", "TronLink collaboration service rejected the request", { code }); + } + return root.data; +} + +function parsePage(value: unknown, requestedLimit: number): TronLinkRemotePage { + const data = record(value, "data"); + const total = safeInteger(data.range_total, "data.range_total", 0); + if (!Array.isArray(data.data) || data.data.length > requestedLimit) { + throw new ChainError("provider_error", "TronLink response contains an invalid transaction page"); + } + const records = data.data.map((value, index) => { + const item = record(value, `data.data[${index}]`); + return { + hash: item.hash, + contract_type: item.contract_type, + state: item.state, + is_sign: item.is_sign, + current_weight: item.current_weight, + threshold: item.threshold, + contract_data: item.contract_data, + originator_address: item.originator_address, + current_transaction: item.current_transaction, + signature_progress: item.signature_progress, + } satisfies TronLinkRemoteRecord; + }); + return { total, records }; +} + +function record(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ChainError("provider_error", `TronLink ${field} must be an object`); + } + return value as Record; +} + +function safeInteger(value: unknown, field: string, minimum: number): number { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= minimum) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed) && parsed >= minimum) return parsed; + } + throw new ChainError("provider_error", `TronLink ${field} must be a safe integer`); +} + +function normalizeLossless(value: unknown): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + const numeric = Number(exact); + return Number.isSafeInteger(numeric) ? numeric : exact; + } + if (Array.isArray(value)) return value.map(normalizeLossless); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeLossless(item)])); + } + return value; +} + +function rawDataBytes(data: RawData): Buffer { + if (Buffer.isBuffer(data)) return data; + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (Array.isArray(data)) return Buffer.concat(data); + throw new ChainError("provider_error", "TronLink WebSocket returned an unsupported frame type"); +} diff --git a/ts/src/application/contracts/execution-scope.ts b/ts/src/application/contracts/execution-scope.ts index d497f0090..2b352998c 100644 --- a/ts/src/application/contracts/execution-scope.ts +++ b/ts/src/application/contracts/execution-scope.ts @@ -1,4 +1,4 @@ -import type { AccountRef, ChainFamily } from "../../domain/types/index.js"; +import type { AccountRef, ChainFamily, WarningView } from "../../domain/types/index.js"; import type { ProgressEvent } from "./progress.js"; export interface AccountScope { @@ -13,5 +13,5 @@ export interface TransactionScope extends AccountScope { emit(event: ProgressEvent): void; /** surface a non-fatal warning: captured into the JSON envelope's meta.warnings and, in text * mode, printed to stderr. Use when an outcome silently degrades (e.g. --wait not confirmed). */ - warn(message: string): void; + warn(message: string | WarningView): void; } diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 6ac8bb196..53e3ac808 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -1,4 +1,10 @@ -import type { FeeReport, UnsignedTx } from "../../../domain/types/index.js"; +import type { + AccountPermissionsView, + BroadcastResult, + FeeReport, + TronTransactionArtifact, + UnsignedTx, +} from "../../../domain/types/index.js"; import type { RpcResourceCode } from "../../../domain/resources/index.js"; import type { Broadcaster } from "./broadcaster.js"; @@ -140,10 +146,38 @@ export interface TronNodeInfo { [key: string]: unknown; } +export interface TronSignWeight { + permission: { + id: number; + name: string; + threshold: number; + operationsHex?: string; + keys: Array<{ address: string; weight: number }>; + } | null; + approvedList: string[]; + currentWeight: number; + resultCode: string; + message?: string; +} + /** TRON-specific application boundary; chain-specific capabilities remain explicit. */ export interface TronGateway extends Broadcaster { + prepareTransaction( + transaction: UnsignedTx, + options: { permissionId: number; expiration?: number }, + ): UnsignedTx; + encodeTransactionHex(transaction: UnsignedTx): string; + decodeTransactionHex(hex: string): TronTransactionArtifact; + getAccountPermissions(address: string): Promise; + buildAccountPermissionUpdate(owner: string, permissions: AccountPermissionsView): Promise; + getSignWeight(transaction: UnsignedTx): Promise; + getApprovedList(transaction: UnsignedTx): Promise; + broadcastHex(hex: string): Promise; + getUpdateAccountPermissionFee(): Promise; + getMultiSignFee(): Promise; getNativeBalance(address: string): Promise; getAccount(address: string): Promise; + getAccountById(accountId: string): Promise; getAccountResources(address: string): Promise; getBlock(number?: string): Promise; getTransactionById(txid: string): Promise; @@ -163,6 +197,9 @@ export interface TronGateway extends Broadcaster { getTrc10Balance(assetId: string, address: string): Promise; getTrc10Info(assetId: string): Promise; buildNativeTransfer(from: string, to: string, amountSun: string): Promise; + buildAccountCreate(owner: string, target: string): Promise; + buildAccountUpdate(owner: string, accountName: string): Promise; + buildSetAccountId(owner: string, accountId: string): Promise; buildTrc20Transfer( from: string, to: string, diff --git a/ts/src/application/ports/contact-repository.ts b/ts/src/application/ports/contact-repository.ts new file mode 100644 index 000000000..81566a7bf --- /dev/null +++ b/ts/src/application/ports/contact-repository.ts @@ -0,0 +1,8 @@ +import type { ChainFamily, ContactEntry } from "../../domain/types/index.js"; + +export interface ContactRepository { + add(entry: ContactEntry): ContactEntry; + list(family: ChainFamily): ContactEntry[]; + find(family: ChainFamily, nameKey: string): ContactEntry | undefined; + remove(family: ChainFamily, nameKey: string): ContactEntry; +} diff --git a/ts/src/application/ports/gasfree-provider.ts b/ts/src/application/ports/gasfree-provider.ts new file mode 100644 index 000000000..06e630e0c --- /dev/null +++ b/ts/src/application/ports/gasfree-provider.ts @@ -0,0 +1,20 @@ +import type { + GasFreeAddressInfo, + GasFreeProviderConfig, + GasFreeTokenConfig, + GasFreeTransferRecord, + NetworkDescriptor, + SignedGasFreeAuthorization, +} from "../../domain/types/index.js"; + +/** Outbound boundary for the official GasFree Open Platform. */ +export interface GasFreeProvider { + listTokens(network: NetworkDescriptor): Promise; + listProviders(network: NetworkDescriptor): Promise; + getAddress(network: NetworkDescriptor, ownerAddress: string): Promise; + submitTransfer( + network: NetworkDescriptor, + authorization: SignedGasFreeAuthorization, + ): Promise; + trace(network: NetworkDescriptor, traceId: string): Promise; +} diff --git a/ts/src/application/ports/keypair-writer.ts b/ts/src/application/ports/keypair-writer.ts new file mode 100644 index 000000000..1fcb9c884 --- /dev/null +++ b/ts/src/application/ports/keypair-writer.ts @@ -0,0 +1,3 @@ +export interface KeypairWriter { + write(path: string, value: unknown): string; +} diff --git a/ts/src/application/ports/qr-encoder.ts b/ts/src/application/ports/qr-encoder.ts new file mode 100644 index 000000000..0dfad8a08 --- /dev/null +++ b/ts/src/application/ports/qr-encoder.ts @@ -0,0 +1,4 @@ +export interface QrEncoder { + /** Returns null when the output device cannot display the full matrix safely. */ + encode(payload: string): string | null; +} diff --git a/ts/src/application/ports/tronlink-collaboration.ts b/ts/src/application/ports/tronlink-collaboration.ts new file mode 100644 index 000000000..8371a1c26 --- /dev/null +++ b/ts/src/application/ports/tronlink-collaboration.ts @@ -0,0 +1,59 @@ +import type { NetworkDescriptor, TronTransactionArtifact } from "../../domain/types/index.js"; + +export interface TronLinkListFilter { + state: number; + isSigned?: boolean; + start: number; + limit: number; +} + +/** Untrusted wire record. Application code validates every field before using or rendering it. */ +export interface TronLinkRemoteRecord { + hash: unknown; + contract_type: unknown; + state: unknown; + is_sign: unknown; + current_weight: unknown; + threshold: unknown; + contract_data: unknown; + originator_address: unknown; + current_transaction: unknown; + signature_progress: unknown; +} + +export interface TronLinkRemotePage { + total: number; + records: TronLinkRemoteRecord[]; +} + +export interface TronLinkCreateRequest { + permissionName: string; + txId: string; + rawDataJson: string; + contractType: string; +} + +/** Outbound boundary for the official walletadapter REST/WebSocket collaboration service. */ +export interface TronLinkCollaborationPort { + list( + network: NetworkDescriptor, + address: string, + filter: TronLinkListFilter, + ): Promise; + create( + network: NetworkDescriptor, + address: string, + request: TronLinkCreateRequest, + ): Promise; + submit( + network: NetworkDescriptor, + address: string, + transaction: TronTransactionArtifact, + ): Promise; + watch( + network: NetworkDescriptor, + address: string, + signal: AbortSignal, + onMessage: (payload: unknown) => void, + ): Promise; +} diff --git a/ts/src/application/services/pipeline/index.ts b/ts/src/application/services/pipeline/index.ts index e54d82f48..ff088edf2 100644 --- a/ts/src/application/services/pipeline/index.ts +++ b/ts/src/application/services/pipeline/index.ts @@ -10,6 +10,7 @@ import { SignerResolver } from "../signer/index.js"; import { UsageError } from "../../../domain/errors/index.js"; import { obtainSignature } from "../signing/obtain-signature.js"; import type { Broadcaster } from "../../ports/chain/broadcaster.js"; +import type { TransactionExecutionMode } from "../transaction-mode.js"; export interface TxPipelineParams { ctx: TransactionScope; @@ -20,7 +21,18 @@ export interface TxPipelineParams { build: (signerAddress: string) => Promise; estimate: (tx: UnsignedTx) => Promise; dryRun: boolean; + buildOnly?: boolean; broadcast: boolean; + mode?: TransactionExecutionMode; + permissionId?: number; + expiration?: number; + signerOptions?: { requireSoftware?: boolean }; + /** Bind permission/expiration and recompute transaction identity before signing. */ + prepare?: (tx: UnsignedTx, options: { permissionId: number; expiration?: number }) => Promise | UnsignedTx; + /** Complete transaction protobuf serializer, required for build-only. */ + artifact?: (tx: UnsignedTx | SignedTx) => string; + /** Authorization check performed before software key decryption or Ledger interaction. */ + preflight?: (tx: UnsignedTx, signerAddress: string) => Promise; /** Optional post-broadcast confirmation: poll the chain for on-chain results (fee/energy/ * withdrawn amount) and merge them into the broadcast outcome. Best-effort — it must never * throw; on timeout it returns undefined and the receipt falls back to txid + echoed inputs. */ @@ -54,22 +66,50 @@ export class TxPipeline { } async run(p: TxPipelineParams): Promise { - // --wait only makes sense when we actually broadcast (dry-run/sign-only never reach the chain). - if (p.ctx.wait && !p.broadcast) { - throw new UsageError("invalid_option", "--wait has nothing to wait for with --dry-run/--sign-only (neither broadcasts)"); + const mode: TransactionExecutionMode = p.mode + ?? (p.dryRun ? "dry-run" : p.buildOnly ? "build-only" : p.broadcast ? "broadcast" : "sign-only"); + if (p.ctx.wait && mode !== "broadcast") { + throw new UsageError("invalid_option", "--wait cannot be used with --dry-run, --sign-only, or --build-only"); } - const signer = this.signers.resolve(p.account, p.net.family); // RPC steps (build/estimate/broadcast) are bounded by the adapter's own --timeout, so they // aren't wrapped here. The one thing no RPC timeout covers is a Ledger tap that never comes; // obtainSignature bounds the device signature and aborts its prompt on timeout. - const tx = await p.build(signer.address); + const ownerAddress = p.ctx.resolveAddress(p.net.family); + let tx = await p.build(ownerAddress); + if (p.prepare) { + tx = await p.prepare(tx, { + permissionId: p.permissionId ?? 0, + ...(p.expiration === undefined ? {} : { expiration: p.expiration }), + }); + } else if ((p.permissionId ?? 0) !== 0 || p.expiration !== undefined) { + throw new UsageError("invalid_option", "this chain adapter cannot apply --permission-id or --expiration"); + } const fee = await p.estimate(tx); - if (p.dryRun) return { stage: "plan", tx, fee }; + if (mode === "dry-run") return { stage: "plan", tx, fee }; + if (mode === "build-only") { + if (!p.artifact) throw new UsageError("invalid_option", "this chain adapter cannot produce transaction hex"); + return { stage: "built", tx, hex: p.artifact(tx), fee }; + } + this.signers.assertCanSign(p.account, p.net.family, p.signerOptions); + const signer = this.signers.resolve(p.account, p.net.family); + if (signer.address !== ownerAddress) { + throw new UsageError("invalid_account", "resolved signer address changed during transaction construction"); + } + await p.preflight?.(tx, signer.address); const signed = await obtainSignature(signer, p.ctx, (opts) => signer.sign(tx, opts)); - if (!p.broadcast) return { stage: "signed", signed, fee, address: signer.address, txId: txIdOf(signed) }; + if (mode === "sign-only") { + return { + stage: "signed", + signed, + ...(p.artifact ? { hex: p.artifact(signed) } : {}), + fee, + address: signer.address, + txId: txIdOf(signed), + }; + } const result = await p.broadcaster.broadcast(signed); const txId = String(result.txId ?? result.hash ?? ""); // default (no --wait): non-blocking, return the submitted txid only (fee/energy unknown yet). diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index 0eb0bf3e6..1dde8078e 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -47,9 +47,50 @@ describe("TxPipeline device-sign timeout", () => { signMessage: async () => "", signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), }; - const signers = { resolve: () => signer } as unknown as SignerResolver; + const signers = { assertCanSign: () => {}, resolve: () => signer } as unknown as SignerResolver; await expect(new TxPipeline(signers).run(params(signer))).rejects.toMatchObject({ code: "timeout" }); expect(captured?.aborted).toBe(true); // the abort is wired so the device prompt is cancelled }); + + it("build-only uses the public address and never resolves or unlocks a signer", async () => { + const signer: Signer = { + kind: "software", + address: "TSender", + sign: vi.fn(async (tx) => tx), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + const result = await new TxPipeline(signers).run(params(signer, { + mode: "build-only", + buildOnly: true, + prepare: (tx) => tx, + artifact: () => "abcd", + })); + + expect(result).toMatchObject({ stage: "built", hex: "abcd" }); + expect(signers.assertCanSign).not.toHaveBeenCalled(); + expect(signers.resolve).not.toHaveBeenCalled(); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("preflights authorization before invoking the signer", async () => { + const order: string[] = []; + const signer: Signer = { + kind: "software", + address: "TSender", + sign: vi.fn(async (tx) => { order.push("sign"); return tx; }), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { assertCanSign: vi.fn(), resolve: () => signer } as unknown as SignerResolver; + await new TxPipeline(signers).run(params(signer, { + preflight: async () => { order.push("preflight"); }, + })); + expect(order).toEqual(["preflight", "sign"]); + }); }); diff --git a/ts/src/application/services/recipient-resolver.test.ts b/ts/src/application/services/recipient-resolver.test.ts new file mode 100644 index 000000000..5a5b17463 --- /dev/null +++ b/ts/src/application/services/recipient-resolver.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { ContactRepository } from "../ports/contact-repository.js"; +import { RecipientResolver } from "./recipient-resolver.js"; + +const VALID = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const ALICE = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; + +describe("RecipientResolver", () => { + const repository = { + find: (_family: string, key: string) => key === "alice" + ? { + family: "tron", + name: "Alice", + nameKey: "alice", + address: ALICE, + note: null, + } + : undefined, + } as ContactRepository; + const resolver = new RecipientResolver(repository); + + it("uses a valid address first and otherwise resolves the canonical contact name", () => { + expect(resolver.resolve("tron", VALID)).toEqual({ address: VALID }); + expect(resolver.resolve("tron", "ALICE")).toEqual({ + address: ALICE, + contactName: "Alice", + }); + }); + + it("never falls back to a contact for a mistyped address-shaped value", () => { + expect(() => + resolver.resolve("tron", "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX") + ).toThrow(/checksum/); + }); + + it("returns a stable contact_not_found error for an unknown name", () => { + try { + resolver.resolve("tron", "unknown"); + throw new Error("expected resolver to throw"); + } catch (error) { + expect(error).toMatchObject({ code: "contact_not_found" }); + } + }); +}); diff --git a/ts/src/application/services/recipient-resolver.ts b/ts/src/application/services/recipient-resolver.ts new file mode 100644 index 000000000..515d8231c --- /dev/null +++ b/ts/src/application/services/recipient-resolver.ts @@ -0,0 +1,39 @@ +import type { ContactRepository } from "../ports/contact-repository.js"; +import type { + ChainFamily, + ResolvedRecipient, +} from "../../domain/types/index.js"; +import { TronAddress } from "../../domain/address/index.js"; +import { + contactNameKey, + resemblesTronAddress, +} from "../../domain/contact/index.js"; +import { UsageError } from "../../domain/errors/index.js"; + +export class RecipientResolver { + readonly #tron = new TronAddress(); + + constructor(private readonly contacts: ContactRepository) {} + + resolve(family: ChainFamily, input: string): ResolvedRecipient { + const value = input.trim(); + if (family === "tron" && this.#tron.validate(value)) { + return { address: value }; + } + // Never let a checksum typo fall through to a same-looking contact alias. + if (family === "tron" && resemblesTronAddress(value)) { + throw new UsageError( + "invalid_value", + "recipient resembles a TRON address but has an invalid length or checksum", + ); + } + const entry = this.contacts.find(family, contactNameKey(value)); + if (!entry) { + throw new UsageError( + "contact_not_found", + `contact not found: ${value}`, + ); + } + return { address: entry.address, contactName: entry.name }; + } +} diff --git a/ts/src/application/services/target/index.ts b/ts/src/application/services/target/index.ts index 0254a929a..78df434bc 100644 --- a/ts/src/application/services/target/index.ts +++ b/ts/src/application/services/target/index.ts @@ -4,6 +4,7 @@ import type { NetworkRegistry } from "../../ports/network-registry.js"; import { UsageError } from "../../../domain/errors/index.js"; import { sourceFamily } from "../../../domain/sources/index.js"; import type { AccountStore } from "../../ports/account-store.js"; +import { familyOf } from "../../../domain/family/index.js"; export interface TargetResolverDeps { networkRegistry: NetworkRegistry; @@ -54,6 +55,8 @@ export class TargetResolver { #singleFamilyAccount(selection: ExecutionSelection): ChainFamily | undefined { const ref = selection.account ?? this.deps.keystore.activeAccount() ?? undefined; if (!ref) return undefined; + const directFamily = familyOf(ref); + if (directFamily) return directFamily; const { wallet } = this.deps.keystore.resolveAccount(ref); return sourceFamily(wallet.source); } diff --git a/ts/src/application/services/target/target.test.ts b/ts/src/application/services/target/target.test.ts index 8d21745db..2941df5a0 100644 --- a/ts/src/application/services/target/target.test.ts +++ b/ts/src/application/services/target/target.test.ts @@ -52,6 +52,13 @@ describe("TargetResolver", () => { expect(target.network?.id).toBe("evm:1"); }); + it("detects a direct on-chain address without resolving it from the keystore", () => { + const target = resolver("tron:mainnet").resolve(policy("tron"), { + account: "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", + }); + expect(target.network?.id).toBe("tron:mainnet"); + }); + it("rejects a command implementation that does not support the selected network family", () => { expect(() => resolver("tron:mainnet").resolve(policy("evm" as any), {}), diff --git a/ts/src/application/services/transaction-mode.test.ts b/ts/src/application/services/transaction-mode.test.ts index 061e17cc8..bfa1c425b 100644 --- a/ts/src/application/services/transaction-mode.test.ts +++ b/ts/src/application/services/transaction-mode.test.ts @@ -13,18 +13,35 @@ function expectCode(fn: () => unknown, code: string) { describe("transactionMode", () => { it("no flag → broadcast (default)", () => { - expect(transactionMode({})).toEqual({ dryRun: false, broadcast: true }); + expect(transactionMode({})).toEqual({ + mode: "broadcast", + dryRun: false, + buildOnly: false, + broadcast: true, + permissionId: 0, + }); }); it("--dry-run → build + estimate only", () => { - expect(transactionMode({ dryRun: true })).toEqual({ dryRun: true, broadcast: false }); + expect(transactionMode({ dryRun: true })).toMatchObject({ mode: "dry-run", dryRun: true, broadcast: false }); }); it("--sign-only → sign, do not broadcast", () => { - expect(transactionMode({ signOnly: true })).toEqual({ dryRun: false, broadcast: false }); + expect(transactionMode({ signOnly: true })).toMatchObject({ mode: "sign-only", dryRun: false, broadcast: false }); }); it("--dry-run + --sign-only → invalid_option", () => { expectCode(() => transactionMode({ dryRun: true, signOnly: true }), "invalid_option"); }); + + it("supports unsigned build artifacts and bounded expiration", () => { + expect(transactionMode({ buildOnly: true, permissionId: 2, expiration: 86_400_000 })).toMatchObject({ + mode: "build-only", + permissionId: 2, + expiration: 86_400_000, + }); + expectCode(() => transactionMode({ expiration: 1_000 }), "invalid_option"); + expectCode(() => transactionMode({ signOnly: true, expiration: 86_400_001 }), "invalid_option"); + expectCode(() => transactionMode({ permissionId: 10 }), "invalid_option"); + }); }); diff --git a/ts/src/application/services/transaction-mode.ts b/ts/src/application/services/transaction-mode.ts index 1ea54587a..11f9387a9 100644 --- a/ts/src/application/services/transaction-mode.ts +++ b/ts/src/application/services/transaction-mode.ts @@ -4,22 +4,56 @@ import { UsageError } from "../../domain/errors/index.js"; export interface TransactionModeInput { dryRun?: boolean; signOnly?: boolean; + buildOnly?: boolean; + permissionId?: number; + expiration?: number; } -export function transactionMode(input: TransactionModeInput): { +export type TransactionExecutionMode = "dry-run" | "build-only" | "sign-only" | "broadcast"; + +export interface ResolvedTransactionMode { + mode: TransactionExecutionMode; dryRun: boolean; + buildOnly: boolean; broadcast: boolean; -} { - if (input.dryRun && input.signOnly) { - throw new UsageError("invalid_option", "choose at most one of --dry-run, --sign-only"); + permissionId: number; + expiration?: number; +} + +export function transactionMode(input: TransactionModeInput): ResolvedTransactionMode { + const selected = [input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length; + if (selected > 1) { + throw new UsageError("invalid_option", "choose at most one of --dry-run, --sign-only, --build-only"); + } + const permissionId = input.permissionId ?? 0; + if (!Number.isInteger(permissionId) || permissionId < 0 || permissionId > 9) { + throw new UsageError("invalid_option", "--permission-id must be an integer from 0 to 9"); + } + if (input.expiration !== undefined) { + if (!Number.isInteger(input.expiration) || input.expiration < 1 || input.expiration > 86_400_000) { + throw new UsageError("invalid_option", "--expiration must be an integer from 1 to 86400000 milliseconds"); + } + if (!input.signOnly && !input.buildOnly) { + throw new UsageError("invalid_option", "--expiration is only valid with --sign-only or --build-only"); + } } - if (input.dryRun) return { dryRun: true, broadcast: false }; - if (input.signOnly) return { dryRun: false, broadcast: false }; - return { dryRun: false, broadcast: true }; + const common = { permissionId, ...(input.expiration === undefined ? {} : { expiration: input.expiration }) }; + if (input.dryRun) return { mode: "dry-run", dryRun: true, buildOnly: false, broadcast: false, ...common }; + if (input.buildOnly) return { mode: "build-only", dryRun: false, buildOnly: true, broadcast: false, ...common }; + if (input.signOnly) return { mode: "sign-only", dryRun: false, buildOnly: false, broadcast: false, ...common }; + return { mode: "broadcast", dryRun: false, buildOnly: false, broadcast: true, ...common }; +} + +export function transactionRequiresSigner(input: TransactionModeInput): boolean { + const mode = transactionMode(input).mode; + return mode === "sign-only" || mode === "broadcast"; } export function outcomeData(outcome: TxOutcome): Record { if (outcome.stage === "plan") return { mode: "dry-run", fee: outcome.fee, tx: outcome.tx }; + if (outcome.stage === "built") { + return { mode: "build-only", tx: outcome.tx, hex: outcome.hex, fee: outcome.fee }; + } if (outcome.stage === "signed") { // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. // Omit rather than emit undefined — kv() drops empty rows and JSON stays additive. @@ -29,8 +63,8 @@ export function outcomeData(outcome: TxOutcome): Record { ...(outcome.fee === undefined ? {} : { fee: outcome.fee }), ...(outcome.address === undefined ? {} : { address: outcome.address }), ...(outcome.txId === undefined ? {} : { txId: outcome.txId }), + ...(outcome.hex === undefined ? {} : { hex: outcome.hex }), }; } return outcome as unknown as Record; } - diff --git a/ts/src/application/use-cases/address-service.test.ts b/ts/src/application/use-cases/address-service.test.ts new file mode 100644 index 000000000..67e66b59a --- /dev/null +++ b/ts/src/application/use-cases/address-service.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { AddressService } from "./address-service.js"; + +const SECRET = "0".repeat(63) + "1"; + +describe("AddressService", () => { + it("uses a valid scalar, zeroizes its buffer, and omits it from the default result", () => { + const writer = { write: vi.fn(() => "/safe/key.json") }; + const scalar = Uint8Array.from([...new Uint8Array(31), 1]); + const result = new AddressService( + "/safe", + writer, + () => scalar, + ).generate({ printSecret: false }); + + expect(result).toEqual({ + tron: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + evm: "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + secretFile: "/safe/key.json", + }); + expect(JSON.stringify(result)).not.toContain(SECRET); + expect(writer.write).toHaveBeenCalledWith( + "/safe/generated/keypair-TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + expect.objectContaining({ privateKey: SECRET }), + ); + expect(scalar.every((byte) => byte === 0)).toBe(true); + }); + + it("returns the private key only when printSecret is explicit", () => { + const writer = { write: vi.fn() }; + const result = new AddressService( + "/safe", + writer, + () => Uint8Array.from([...new Uint8Array(31), 1]), + ).generate({ printSecret: true }); + + expect(result.privateKey).toBe(SECRET); + expect(writer.write).not.toHaveBeenCalled(); + }); + + it("bounds rejection sampling when an entropy source is broken", () => { + const random = vi.fn(() => new Uint8Array(32)); + expect(() => + new AddressService("/safe", { write: vi.fn() }, random) + .generate({ printSecret: false }) + ).toThrow(/valid secp256k1/); + expect(random).toHaveBeenCalledTimes(128); + }); +}); diff --git a/ts/src/application/use-cases/address-service.ts b/ts/src/application/use-cases/address-service.ts new file mode 100644 index 000000000..d4da528b7 --- /dev/null +++ b/ts/src/application/use-cases/address-service.ts @@ -0,0 +1,68 @@ +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import type { KeypairWriter } from "../ports/keypair-writer.js"; +import { + evmAddressFromPublicKey, + TronAddress, +} from "../../domain/address/index.js"; +import { ExecutionError } from "../../domain/errors/index.js"; + +const MAX_SCALAR_ATTEMPTS = 128; + +export class AddressService { + constructor( + private readonly root: string, + private readonly writer: KeypairWriter, + private readonly random: (size: number) => Uint8Array = randomBytes, + ) {} + + generate(input: { out?: string; printSecret: boolean }) { + let privateKey: Uint8Array | undefined; + for ( + let attempt = 0; + attempt < MAX_SCALAR_ATTEMPTS; + attempt += 1 + ) { + const candidate = this.random(32); + if ( + candidate.length === 32 + && secp256k1.utils.isValidSecretKey(candidate) + ) { + privateKey = candidate; + break; + } + candidate.fill(0); + } + if (!privateKey) { + throw new ExecutionError( + "entropy_failure", + "could not obtain a valid secp256k1 private key", + ); + } + + const publicKey = secp256k1.getPublicKey(privateKey, false); + const tron = new TronAddress().fromPublicKey(publicKey); + const evm = evmAddressFromPublicKey(publicKey); + const privateKeyHex = bytesToHex(privateKey); + try { + if (input.printSecret) { + return { tron, evm, privateKey: privateKeyHex }; + } + const path = + input.out + ?? join(this.root, "generated", `keypair-${tron}`); + const secretFile = this.writer.write(path, { + version: 1, + privateKey: privateKeyHex, + publicKey: bytesToHex(publicKey), + tron, + evm, + }); + return { tron, evm, secretFile }; + } finally { + privateKey.fill(0); + } + } +} diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index af7b15917..f3eb40efd 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -55,3 +55,65 @@ describe("ConfigService waitTimeoutMs", () => { expect(update).toHaveBeenCalledTimes(2); }); }); + +describe("ConfigService TronLink credentials", () => { + it("validates the exact public config keys and masks the secret key", () => { + const { svc } = service(); + expect(svc.execute( + { key: "tronlinkSecretId", value: "TEST" }, + effective, + networks, + )).toMatchObject({ key: "tronlinkSecretId", value: "TEST" }); + expect(svc.execute( + { key: "tronlinkSecretKey", value: "TESTTESTTEST" }, + effective, + networks, + )).toMatchObject({ key: "tronlinkSecretKey", value: "********" }); + }); + + it("never returns an effective secret key in config views", () => { + const configured = { ...effective, tronlinkSecretKey: "secret" }; + const { svc } = service(); + expect(svc.execute({}, configured, networks)) + .toMatchObject({ tronlinkSecretKey: "********" }); + expect(svc.execute({ key: "tronlinkSecretKey" }, configured, networks)) + .toEqual({ key: "tronlinkSecretKey", value: "********" }); + }); + + it("rejects empty, oversized, and control-character credentials", () => { + const { svc, update } = service(); + for (const value of ["", "x".repeat(257), "bad\nvalue"]) { + expect(() => svc.execute( + { key: "tronlinkChannel", value }, + effective, + networks, + )).toThrow(); + } + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe("ConfigService GasFree credentials", () => { + it("writes the documented flat keys and masks the API secret", () => { + const { svc } = service(); + expect(svc.execute( + { key: "gasfreeApiKey", value: "TEST" }, + effective, + networks, + )).toMatchObject({ key: "gasfreeApiKey", value: "TEST" }); + expect(svc.execute( + { key: "gasfreeApiSecret", value: "TESTTESTTEST" }, + effective, + networks, + )).toMatchObject({ key: "gasfreeApiSecret", value: "********" }); + }); + + it("never returns an effective API secret in config views", () => { + const configured = { ...effective, gasfreeApiSecret: "secret" }; + const { svc } = service(); + expect(svc.execute({}, configured, networks)) + .toMatchObject({ gasfreeApiSecret: "********" }); + expect(svc.execute({ key: "gasfreeApiSecret" }, configured, networks)) + .toEqual({ key: "gasfreeApiSecret", value: "********" }); + }); +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 1b9aabbba..d37f09d35 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -3,8 +3,32 @@ import type { NetworkRegistry } from "../ports/network-registry.js"; import { UsageError } from "../../domain/errors/index.js"; import type { ConfigDocumentRepository } from "../ports/config-document-repository.js"; -export const CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs", "networks"] as const; -export const WRITABLE_CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs"] as const; +export const TRONLINK_CONFIG_KEYS = [ + "tronlinkSecretId", + "tronlinkSecretKey", + "tronlinkChannel", +] as const; +export const GASFREE_CONFIG_KEYS = [ + "gasfreeApiKey", + "gasfreeApiSecret", +] as const; +export const CONFIG_KEYS = [ + "defaultNetwork", + "defaultOutput", + "timeoutMs", + "waitTimeoutMs", + "networks", + ...TRONLINK_CONFIG_KEYS, + ...GASFREE_CONFIG_KEYS, +] as const; +export const WRITABLE_CONFIG_KEYS = [ + "defaultNetwork", + "defaultOutput", + "timeoutMs", + "waitTimeoutMs", + ...TRONLINK_CONFIG_KEYS, + ...GASFREE_CONFIG_KEYS, +] as const; export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; @@ -27,6 +51,11 @@ export class ConfigService { timeoutMs: effective.timeoutMs, waitTimeoutMs: effective.waitTimeoutMs, networks: Object.keys(effective.networks), + tronlinkSecretId: effective.tronlinkSecretId, + tronlinkSecretKey: maskSecret(effective.tronlinkSecretKey), + tronlinkChannel: effective.tronlinkChannel, + gasfreeApiKey: effective.gasfreeApiKey, + gasfreeApiSecret: maskSecret(effective.gasfreeApiSecret), }; if (input.key === undefined) return view; if (input.value === undefined) return { key: input.key, value: view[input.key] }; @@ -36,6 +65,12 @@ export class ConfigService { const key = input.key as WritableConfigKey; const value = this.normalize(key, input.value, networks); + if (key === "tronlinkSecretKey" || key === "gasfreeApiSecret") { + return this.documents.update((current) => ({ + document: { ...current, [key]: value }, + result: { key, value: maskSecret(String(value)), input: "********" }, + })); + } return this.documents.update((current) => ({ document: { ...current, [key]: value }, result: { key, value, input: input.value! }, @@ -67,6 +102,19 @@ export class ConfigService { } return raw; } + if ( + (TRONLINK_CONFIG_KEYS as readonly string[]).includes(key) + || (GASFREE_CONFIG_KEYS as readonly string[]).includes(key) + ) { + if (raw.length === 0 || raw.length > 256 || /[\u0000-\u001f\u007f]/.test(raw)) { + throw new UsageError("invalid_value", `${key} must be 1 to 256 characters without control characters`); + } + return raw; + } return networks.resolve(raw).id; } } + +function maskSecret(value: string | undefined): string | undefined { + return value ? "********" : undefined; +} diff --git a/ts/src/application/use-cases/contact-service.ts b/ts/src/application/use-cases/contact-service.ts new file mode 100644 index 000000000..ec38f2a5b --- /dev/null +++ b/ts/src/application/use-cases/contact-service.ts @@ -0,0 +1,39 @@ +import type { ContactRepository } from "../ports/contact-repository.js"; +import type { + ContactEntry, + ContactListView, + ContactView, +} from "../../domain/types/index.js"; +import { + contactNameKey, + createContact, +} from "../../domain/contact/index.js"; + +export class ContactService { + constructor(private readonly contacts: ContactRepository) {} + + add(name: string, address: string, note?: string): ContactView { + return publicContact( + this.contacts.add(createContact("tron", name, address, note)), + ); + } + + list(): ContactListView { + return { contacts: this.contacts.list("tron").map(publicContact) }; + } + + remove(name: string): ContactView { + return publicContact( + this.contacts.remove("tron", contactNameKey(name)), + ); + } +} + +function publicContact(entry: ContactEntry): ContactView { + return { + name: entry.name, + address: entry.address, + note: entry.note, + family: entry.family, + }; +} diff --git a/ts/src/application/use-cases/encoding-service.ts b/ts/src/application/use-cases/encoding-service.ts new file mode 100644 index 000000000..4ea4128a6 --- /dev/null +++ b/ts/src/application/use-cases/encoding-service.ts @@ -0,0 +1,7 @@ +import { convertEncoding } from "../../domain/encoding/index.js"; + +export class EncodingService { + convert(input: string) { + return convertEncoding(input); + } +} diff --git a/ts/src/application/use-cases/tron/account-service.test.ts b/ts/src/application/use-cases/tron/account-service.test.ts index 4a131c756..c00824be8 100644 --- a/ts/src/application/use-cases/tron/account-service.test.ts +++ b/ts/src/application/use-cases/tron/account-service.test.ts @@ -1,14 +1,18 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { TronAccountService } from "./account-service.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -import type { AccountScope } from "../../contracts/execution-scope.js"; +import type { AccountScope, TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TronHistoryReader } from "../../ports/chain/tron-history-reader.js"; import type { TokenRepository } from "../../ports/token-repository.js"; import type { PriceProvider } from "../../ports/price-provider.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; const net: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: ["nile"], capabilities: [] }; const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "TXaddress" }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const TARGET = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; function serviceWith(nativeRaw: string, nativePrice: number | null) { const gateway = { @@ -23,7 +27,7 @@ function serviceWith(nativeRaw: string, nativePrice: number | null) { nativeUsd: async () => nativePrice, tokenUsd: async () => new Map(), } as unknown as PriceProvider; - return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices); + return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices, {} as never); } describe("TronAccountService.balance (direction A shape)", () => { @@ -66,7 +70,7 @@ describe("TronAccountService.portfolio per-token best-effort (issue #9)", () => const gateways = { client: () => gateway, get: () => gateway } as unknown as ChainGatewayProvider; const tokens = { effective: () => [TOK] } as unknown as TokenRepository; const prices = { source: "test", nativeUsd: async () => 0.1, tokenUsd: async () => new Map([[TOK.id, 1]]) } as unknown as PriceProvider; - return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices); + return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices, {} as never); } it("one unreadable token degrades to a stable reason without leaking the raw error (I-06)", async () => { @@ -100,9 +104,227 @@ describe("TronAccountService.portfolio per-token best-effort (issue #9)", () => nativeUsd: async () => { throw new Error("request to https://api.provider.com/?key=SECRET failed"); }, tokenUsd: async () => new Map(), } as unknown as PriceProvider; - const result = await new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices).portfolio(scope, net); + const result = await new TronAccountService( + gateways, + {} as unknown as TronHistoryReader, + tokens, + prices, + {} as never, + ).portfolio(scope, net); expect(result.priceUnavailable).toBe(true); expect(result.priceReason).toBe("price_provider_error"); expect(JSON.stringify(result)).not.toContain("SECRET"); }); }); + +function transactionScope(wait = false): TransactionScope { + return { + activeAccount: "wlt_test.0", + timeoutMs: 100, + wait, + waitTimeoutMs: 100, + resolveAddress: () => OWNER, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function writeService(options: { + balance?: string; + getAccount?: (address: string) => Promise>; + getAccountById?: (id: string) => Promise>; + outcome?: Record; +} = {}) { + const gateway = { + getAccount: vi.fn(options.getAccount ?? (async () => ({}))), + getAccountById: vi.fn(options.getAccountById ?? (async () => ({}))), + getChainParameters: vi.fn(async () => [ + { key: "getCreateAccountFee", value: 100_000 }, + { key: "getCreateNewAccountFeeInSystemContract", value: 1_000_000 }, + ]), + getNativeBalance: vi.fn(async () => options.balance ?? "2000000"), + buildAccountCreate: vi.fn(async () => ({ txID: "create" })), + buildAccountUpdate: vi.fn(async () => ({ txID: "name" })), + buildSetAccountId: vi.fn(async () => ({ txID: "id" })), + prepareTransaction: vi.fn((transaction) => transaction), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway; + let captured: TxPipelineParams | undefined; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + captured = params; + if (options.outcome) return options.outcome; + const transaction = await params.build(OWNER); + const fee = await params.estimate(transaction); + if (params.buildOnly) { + return { + stage: "built" as const, + tx: transaction, + hex: params.artifact!(transaction), + fee, + }; + } + return { + stage: "plan" as const, + tx: transaction, + fee, + }; + }), + } as unknown as TxPipeline; + const provider = { + get: () => gateway, + } as unknown as ChainGatewayProvider; + const service = new TronAccountService( + provider, + {} as TronHistoryReader, + {} as TokenRepository, + {} as PriceProvider, + pipeline, + ); + return { service, gateway, pipeline, captured: () => captured }; +} + +describe("TronAccountService account lifecycle writes", () => { + it("dry-runs activation with both dynamic creation fees and no signer gate", async () => { + const { service, gateway, pipeline, captured } = writeService(); + const result = await service.activate(transactionScope(), net, { + address: TARGET, + dryRun: true, + permissionId: 2, + }); + + expect(result).toMatchObject({ + kind: "account-activate", + mode: "dry-run", + address: TARGET, + payer: OWNER, + fee: { + createAccountFeeSun: "100000", + systemContractFeeSun: "1000000", + minimumFeeSun: "1100000", + }, + }); + expect(gateway.buildAccountCreate).toHaveBeenCalledWith(OWNER, TARGET); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + expect(captured()?.permissionId).toBe(2); + }); + + it("rejects an already activated target before transaction construction", async () => { + const { service, pipeline } = writeService({ + getAccount: async () => ({ address: TARGET }), + }); + + await expect(service.activate(transactionScope(), net, { + address: TARGET, + dryRun: true, + })).rejects.toMatchObject({ code: "account_already_active" }); + expect(pipeline.run).not.toHaveBeenCalled(); + }); + + it("enforces the complete activation fee for dry-run and broadcast", async () => { + const { service, pipeline } = writeService({ balance: "1099999" }); + + await expect(service.activate(transactionScope(), net, { + address: TARGET, + dryRun: true, + })).rejects.toMatchObject({ + code: "insufficient_balance", + details: { balance: "1099999", required: "1100000" }, + }); + expect(pipeline.run).not.toHaveBeenCalled(); + }); + + it("allows an offline account build even when the payer balance is currently low", async () => { + const { service } = writeService({ balance: "0" }); + const result = await service.activate(transactionScope(), net, { + address: TARGET, + buildOnly: true, + expiration: 60_000, + }); + + expect(result).toMatchObject({ + kind: "account-activate", + mode: "build-only", + hex: "abcd", + }); + }); + + it("builds the exact one-time account name after checking activation", async () => { + const { service, gateway } = writeService({ + getAccount: async () => ({ address: OWNER }), + }); + const result = await service.setOnChain(transactionScope(), net, { + name: "Acme Treasury", + dryRun: true, + }); + + expect(result).toMatchObject({ + kind: "account-set", + mode: "dry-run", + field: "name", + value: "Acme Treasury", + address: OWNER, + }); + expect(gateway.buildAccountUpdate).toHaveBeenCalledWith( + OWNER, + "Acme Treasury", + ); + expect(gateway.getAccountById).not.toHaveBeenCalled(); + }); + + it("rejects a previously set name and a globally occupied ID", async () => { + const named = writeService({ + getAccount: async () => ({ + address: OWNER, + account_name: Buffer.from("existing", "utf8").toString("hex"), + }), + }); + await expect(named.service.setOnChain(transactionScope(), net, { + name: "new-name", + dryRun: true, + })).rejects.toMatchObject({ code: "name_already_set" }); + + const occupied = writeService({ + getAccount: async () => ({ address: OWNER }), + getAccountById: async () => ({ address: TARGET }), + }); + await expect(occupied.service.setOnChain(transactionScope(), net, { + id: "acme-001", + dryRun: true, + })).rejects.toMatchObject({ code: "id_taken" }); + }); + + it("validates name and ID limits in UTF-8 bytes", async () => { + const { service } = writeService({ + getAccount: async () => ({ address: OWNER }), + }); + + await expect(service.setOnChain(transactionScope(), net, { + name: "账".repeat(11), + dryRun: true, + })).rejects.toMatchObject({ code: "invalid_value" }); + await expect(service.setOnChain(transactionScope(), net, { + id: "short", + dryRun: true, + })).rejects.toMatchObject({ code: "invalid_value" }); + await expect(service.setOnChain(transactionScope(), net, { + id: "账".repeat(10), + dryRun: true, + })).resolves.toMatchObject({ field: "id", value: "账".repeat(10) }); + }); + + it("fails a signing mode before RPC when the active account cannot sign", async () => { + const { service, gateway, pipeline } = writeService(); + vi.mocked(pipeline.assertCanSign).mockImplementation(() => { + throw Object.assign(new Error("watch only"), { + code: "watch_only_no_signer", + }); + }); + + await expect(service.activate(transactionScope(), net, { + address: TARGET, + })).rejects.toMatchObject({ code: "watch_only_no_signer" }); + expect(gateway.getAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index f3d3a7c42..0cb781b97 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -1,13 +1,26 @@ import type { ChainFamily, EffectiveTokenEntry, NetworkDescriptor } from "../../../domain/types/index.js"; +import { ChainError, UsageError } from "../../../domain/errors/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { fromBaseUnits } from "../../../domain/amounts/index.js"; -import type { AccountScope } from "../../contracts/execution-scope.js"; +import { TronAddress, tronHexToBase58 } from "../../../domain/address/index.js"; +import type { AccountScope, TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronAccount, TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TronHistoryQuery, TronHistoryReader } from "../../ports/chain/tron-history-reader.js"; import type { PriceProvider } from "../../ports/price-provider.js"; import type { TokenRepository } from "../../ports/token-repository.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; const round6 = (value: number): number => Math.round(value * 1e6) / 1e6; +const ADDRESS = new TronAddress(); function holding( kind: string, @@ -58,8 +71,154 @@ export class TronAccountService { private readonly history: TronHistoryReader, private readonly tokens: TokenRepository, private readonly prices: PriceProvider, + private readonly pipeline: TxPipeline, ) {} + async activate( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionModeInput & { address: string }, + ) { + if (!ADDRESS.validate(input.address)) { + throw new UsageError( + "invalid_value", + "--address must be a valid TRON address", + ); + } + if (transactionRequiresSigner(input)) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); + } + const mode = transactionMode(input); + const gateway = this.gateways.get(network, "tron"); + const existing = await gateway.getAccount(input.address); + if (accountExists(existing, input.address)) { + throw new ChainError( + "account_already_active", + `TRON account is already active: ${input.address}`, + ); + } + const payer = scope.resolveAddress("tron"); + const [fee, balanceSun] = await Promise.all([ + accountCreateFee(gateway), + gateway.getNativeBalance(payer), + ]); + if ( + (mode.mode === "dry-run" || mode.mode === "broadcast") + && BigInt(balanceSun) < BigInt(fee.minimumFeeSun) + ) { + throw new ChainError( + "insufficient_balance", + "payer balance is below the account creation fees", + { balance: balanceSun, required: fee.minimumFeeSun }, + ); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + ...tronTransactionHooks(gateway), + confirm: tronConfirmation(gateway, scope), + build: (owner) => gateway.buildAccountCreate(owner, input.address), + estimate: async () => ({ ...fee, balanceSun }), + }); + if (outcome.stage === "confirmed" && !outcome.failed) { + const activated = await gateway.getAccount(input.address); + if (!accountExists(activated, input.address)) { + throw new ChainError( + "provider_error", + "confirmed account activation is not visible from the selected node", + ); + } + } + return { + kind: "account-activate" as const, + ...outcomeData(outcome), + address: input.address, + payer, + }; + } + + async setOnChain( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionModeInput & { name?: string; id?: string }, + ) { + if ((input.name === undefined) === (input.id === undefined)) { + throw new UsageError( + "invalid_option", + "provide exactly one of --name or --id", + ); + } + if (transactionRequiresSigner(input)) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); + } + const field = input.name !== undefined ? "name" as const : "id" as const; + const value = validateAccountText(field, input.name ?? input.id!); + const address = scope.resolveAddress("tron"); + const gateway = this.gateways.get(network, "tron"); + const account = await gateway.getAccount(address); + if (!accountExists(account, address)) { + throw new ChainError( + "not_found", + `TRON account is not activated: ${address}`, + ); + } + const current = + field === "name" ? account.account_name : account.account_id; + if (accountTextIsSet(current, field)) { + throw new ChainError( + field === "name" ? "name_already_set" : "id_already_set", + `on-chain account ${field} is already set`, + ); + } + if (field === "id") { + const taken = await gateway.getAccountById(value); + if (accountExists(taken)) { + throw new ChainError( + "id_taken", + `account id is already in use: ${value}`, + ); + } + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...transactionMode(input), + ...tronTransactionHooks(gateway), + confirm: tronConfirmation(gateway, scope), + build: (owner) => + field === "name" + ? gateway.buildAccountUpdate(owner, value) + : gateway.buildSetAccountId(owner, value), + estimate: async () => ({ + feeModel: "tron-resource", + note: "account metadata update consumes bandwidth", + }), + }); + if (outcome.stage === "confirmed" && !outcome.failed) { + const updated = await gateway.getAccount(address); + const raw = + field === "name" ? updated.account_name : updated.account_id; + if (decodeAccountText(raw, field) !== value) { + throw new ChainError( + "provider_error", + `confirmed account ${field} does not match the submitted value`, + ); + } + } + return { + kind: "account-set" as const, + ...outcomeData(outcome), + field, + value, + address, + }; + } + async balance(scope: AccountScope, network: NetworkDescriptor, family: ChainFamily) { const address = scope.resolveAddress(family); const meta = FAMILIES[family]; @@ -167,3 +326,108 @@ export class TronAccountService { }; } } + +async function accountCreateFee(gateway: TronGateway) { + const parameters = await gateway.getChainParameters(); + const find = (key: string): bigint => { + const value = parameters.find((entry) => entry.key === key)?.value; + if (!Number.isSafeInteger(value) || value! < 0) { + throw new ChainError( + "provider_error", + `chain parameter is unavailable: ${key}`, + ); + } + return BigInt(value!); + }; + const createAccountFeeSun = find("getCreateAccountFee"); + const systemContractFeeSun = find( + "getCreateNewAccountFeeInSystemContract", + ); + return { + feeModel: "tron-resource" as const, + createAccountFeeSun: createAccountFeeSun.toString(), + systemContractFeeSun: systemContractFeeSun.toString(), + minimumFeeSun: (createAccountFeeSun + systemContractFeeSun).toString(), + }; +} + +function validateAccountText(field: "name" | "id", input: string): string { + const bytes = Buffer.byteLength(input, "utf8"); + const minimum = field === "id" ? 8 : 1; + if ( + bytes < minimum + || bytes > 32 + || /[\p{Cc}\p{Cf}]/u.test(input) + ) { + throw new UsageError( + "invalid_value", + field === "id" + ? "--id must be 8-32 safe UTF-8 bytes" + : "--name must be 1-32 safe UTF-8 bytes", + ); + } + return input; +} + +function accountExists(account: TronAccount, expected?: string): boolean { + const raw = account.address; + if (raw === undefined || raw === null || raw === "") return false; + if (typeof raw !== "string") { + throw new ChainError( + "provider_error", + "TRON node returned a malformed account address", + ); + } + const normalized = tronHexToBase58(raw); + if (!ADDRESS.validate(normalized)) { + throw new ChainError( + "provider_error", + "TRON node returned an invalid account address", + ); + } + if (expected !== undefined && normalized !== expected) { + throw new ChainError( + "provider_error", + "TRON node returned an account address different from the query", + ); + } + return true; +} + +function accountTextIsSet( + value: unknown, + field: "name" | "id", +): boolean { + if (value === undefined || value === null || value === "") return false; + if (typeof value !== "string") { + throw new ChainError( + "provider_error", + `TRON node returned malformed account_${field}`, + ); + } + return true; +} + +function decodeAccountText( + value: unknown, + field: "name" | "id", +): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + if ( + typeof value !== "string" + || !/^(?:[0-9a-fA-F]{2})+$/.test(value) + ) { + throw new ChainError( + "provider_error", + `TRON node returned malformed account_${field}`, + ); + } + const decoded = Buffer.from(value, "hex").toString("utf8"); + if (Buffer.from(decoded, "utf8").toString("hex") !== value.toLowerCase()) { + throw new ChainError( + "provider_error", + `TRON node returned invalid UTF-8 account_${field}`, + ); + } + return decoded; +} diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 8472b4aad..87be1a895 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -3,9 +3,15 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronContractParameter } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; export class TronContractService { constructor( @@ -38,7 +44,7 @@ export class TronContractService { feeLimit: string; }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, @@ -46,6 +52,7 @@ export class TronContractService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (from) => gateway.triggerSmartContract( from, @@ -80,7 +87,9 @@ export class TronContractService { }, ) { // Ledger TRON app firmware cannot sign a CreateSmartContract tx — reject before any device I/O. - this.pipeline.assertCanSign(scope.activeAccount, "tron", { requireSoftware: true }); + if (transactionRequiresSigner(input)) { + this.pipeline.assertCanSign(scope.activeAccount, "tron", { requireSoftware: true }); + } const gateway = this.gateways.get(network, "tron"); let contractAddress: string | undefined; const outcome = await this.pipeline.run({ @@ -89,6 +98,8 @@ export class TronContractService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), + signerOptions: { requireSoftware: true }, confirm: tronConfirmation(gateway, scope), build: async (from) => { const tx = await gateway.deployContract(from, input); diff --git a/ts/src/application/use-cases/tron/gasfree-service.test.ts b/ts/src/application/use-cases/tron/gasfree-service.test.ts new file mode 100644 index 000000000..6cee55a5d --- /dev/null +++ b/ts/src/application/use-cases/tron/gasfree-service.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GasFreeProvider } from "../../ports/gasfree-provider.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { + NetworkDescriptor, + SignedGasFreeAuthorization, +} from "../../../domain/types/index.js"; +import { GasFreeService } from "./gasfree-service.js"; + +const OWNER = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const TOKEN = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; +const PROVIDER = "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E"; +const GASFREE = "TNER12mMVWruqopsW9FQtKxCGfZcEtb3ER"; +const RECEIVER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; +const DIGEST = + "0x006c1bfb7e397bc2975949b80aa099a33a69a9b8835d84a9714723d25652f5ff"; +const SIGNATURE = + "0x580aab9832cf56d8f418711aa55653a02e51fb97a04fcd09c3bd4cd41cd376f73336a48257ca0d74bbcb8f70cc1bf6e310ca31f167a2ef8b2b93317d9f9a68e31b"; +const NETWORK = { + id: "tron:nile", + family: "tron", + chainId: "nile", + aliases: ["nile"], + capabilities: [], + gasfree: { + baseUrl: "https://open-test.gasfree.io", + apiPrefix: "/nile", + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", + }, +} satisfies NetworkDescriptor; + +function scope(wait = false): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 1000, + wait, + waitTimeoutMs: 1000, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function fixture(options: { + active?: boolean; + balance?: string; + mismatchReceipt?: boolean; + badDigest?: boolean; + expiredAtSeconds?: boolean; + terminal?: "success" | "fee-exceeded" | "mutated-recipient"; +} = {}) { + let submittedAuthorization: SignedGasFreeAuthorization | undefined; + const submitTransfer = vi.fn( + async ( + _network: NetworkDescriptor, + authorization: SignedGasFreeAuthorization, + ) => { + submittedAuthorization = authorization; + return { + id: "trace-1", + state: "WAITING" as const, + tokenAddress: authorization.token, + providerAddress: authorization.serviceProvider, + accountAddress: authorization.user, + gasFreeAddress: GASFREE, + targetAddress: options.mismatchReceipt + ? OWNER + : authorization.receiver, + amount: authorization.value, + maxFee: authorization.maxFee, + nonce: authorization.nonce, + expiredAt: options.expiredAtSeconds + ? authorization.deadline + : `${authorization.deadline}000`, + }; + }, + ); + const provider = { + listTokens: vi.fn(async () => [{ + tokenAddress: TOKEN, + activateFee: "1000000", + transferFee: "500000", + symbol: "USDT", + decimals: 6, + }]), + listProviders: vi.fn(async () => [{ + address: PROVIDER, + defaultDeadlineDuration: "60", + }]), + getAddress: vi.fn(async () => ({ + ownerAddress: OWNER, + gasFreeAddress: GASFREE, + active: options.active ?? false, + nonce: "8", + allowSubmit: true, + assets: [{ + tokenAddress: TOKEN, + activateFee: "1000000", + transferFee: "500000", + }], + })), + submitTransfer, + trace: vi.fn(async () => { + const authorization = submittedAuthorization!; + const transferFee = + options.terminal === "fee-exceeded" ? "1600000" : "400000"; + const activateFee = options.active ? "0" : "900000"; + const totalFee = (BigInt(transferFee) + BigInt(activateFee)).toString(); + return { + id: "trace-1", + state: "SUCCEED" as const, + tokenAddress: authorization.token, + providerAddress: authorization.serviceProvider, + accountAddress: authorization.user, + gasFreeAddress: GASFREE, + targetAddress: + options.terminal === "mutated-recipient" + ? OWNER + : authorization.receiver, + amount: authorization.value, + maxFee: authorization.maxFee, + nonce: authorization.nonce, + expiredAt: `${authorization.deadline}000`, + txnHash: "ab".repeat(32), + txnAmount: authorization.value, + txnTransferFee: transferFee, + txnActivateFee: activateFee, + txnTotalFee: totalFee, + txnTotalCost: + (BigInt(authorization.value) + BigInt(totalFee)).toString(), + }; + }), + } as unknown as GasFreeProvider; + const gateway = { + getTokenInfo: vi.fn(async () => ({ symbol: "USDT", decimals: 6 })), + getTrc20Balance: vi.fn( + async () => options.balance ?? "100000000", + ), + }; + const gateways = { + get: () => gateway, + } as unknown as ChainGatewayProvider; + const signer = { + kind: "software" as const, + address: OWNER, + sign: vi.fn(), + signMessage: vi.fn(), + signTypedData: vi.fn(async () => ({ + signature: SIGNATURE, + digest: options.badDigest ? `0x${"00".repeat(32)}` : DIGEST, + primaryType: "PermitTransfer", + })), + }; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + const recipients = { + resolve: vi.fn((_family: string, input: string) => + input === "alice" + ? { address: RECEIVER, contactName: "Alice" } + : { address: input } + ), + }; + let now = 1_700_000_000_000; + const service = new GasFreeService( + provider, + gateways, + signers, + recipients as never, + () => now, + async (milliseconds: number) => { + now += milliseconds; + }, + ); + return { + service, + provider, + submitTransfer, + signer, + signers, + recipients, + }; +} + +describe("GasFreeService", () => { + it("dry-run checks the complete first-transfer cost without signing", async () => { + const fixtureValue = fixture({ active: false }); + const result = await fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: true, + }); + expect(result).toMatchObject({ + stage: "dry-run", + amount: "25000000", + serviceFee: "500000", + activateFee: "1000000", + authorizedMaxFee: "1500000", + totalDeducted: "26500000", + nonce: "8", + deadline: "1700000060", + }); + expect(fixtureValue.signers.assertCanSign).not.toHaveBeenCalled(); + expect(fixtureValue.signer.signTypedData).not.toHaveBeenCalled(); + expect(fixtureValue.submitTransfer).not.toHaveBeenCalled(); + }); + + it("resolves a contact before signing and binds its address into TIP-712", async () => { + const fixtureValue = fixture({ active: true }); + const result = await fixtureValue.service.transfer(scope(), NETWORK, { + to: "alice", + amount: "25", + token: "USDT", + dryRun: false, + }); + + expect(result).toMatchObject({ + to: RECEIVER, + toContact: "Alice", + }); + expect(fixtureValue.submitTransfer).toHaveBeenCalledWith( + NETWORK, + expect.objectContaining({ receiver: RECEIVER }), + ); + }); + + it("signs the exact TIP-712 digest and accepts Java millisecond expiry", async () => { + const fixtureValue = fixture({ active: true }); + const result = await fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }); + expect(result).toMatchObject({ + stage: "submitted", + traceId: "trace-1", + activateFee: "0", + totalDeducted: "25500000", + }); + expect(fixtureValue.signer.signTypedData).toHaveBeenCalledOnce(); + expect(fixtureValue.submitTransfer).toHaveBeenCalledWith( + NETWORK, + expect.objectContaining({ sig: SIGNATURE.slice(2) }), + ); + }); + + it("rejects a signer-reported digest mismatch before submission", async () => { + const fixtureValue = fixture({ badDigest: true }); + await expect( + fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "signing_rejected" }); + expect(fixtureValue.submitTransfer).not.toHaveBeenCalled(); + }); + + it("rejects a receipt that differs from the signed authorization", async () => { + const fixtureValue = fixture({ mismatchReceipt: true }); + await expect( + fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "gasfree_integrity" }); + }); + + it("fails before signing when the balance cannot cover amount plus fees", async () => { + const fixtureValue = fixture({ balance: "100" }); + await expect( + fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "insufficient_token_balance" }); + expect(fixtureValue.signer.signTypedData).not.toHaveBeenCalled(); + }); + + it("revalidates the polled record and returns final charged fees", async () => { + const fixtureValue = fixture({ + active: false, + terminal: "success", + }); + const result = await fixtureValue.service.transfer(scope(true), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }); + expect(result).toMatchObject({ + stage: "confirmed", + state: "SUCCEED", + txId: "ab".repeat(32), + serviceFee: "400000", + activateFee: "900000", + totalDeducted: "26300000", + }); + }); + + it("rejects a final provider fee above the signed maxFee", async () => { + const fixtureValue = fixture({ + active: true, + terminal: "fee-exceeded", + }); + await expect( + fixtureValue.service.transfer(scope(true), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "gasfree_integrity" }); + }); + + it("rejects signed transfer fields that mutate while polling", async () => { + const fixtureValue = fixture({ terminal: "mutated-recipient" }); + await expect( + fixtureValue.service.transfer(scope(true), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "gasfree_integrity" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/gasfree-service.ts b/ts/src/application/use-cases/tron/gasfree-service.ts new file mode 100644 index 000000000..d8fec88d7 --- /dev/null +++ b/ts/src/application/use-cases/tron/gasfree-service.ts @@ -0,0 +1,613 @@ +import { bytesToHex } from "@noble/hashes/utils.js"; +import type { GasFreeProvider } from "../../ports/gasfree-provider.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { + GasFreeAddressInfo, + GasFreeAuthorization, + GasFreeInfoView, + GasFreeProviderConfig, + GasFreeTokenConfig, + GasFreeTraceView, + GasFreeTransferRecord, + GasFreeTransferView, + NetworkDescriptor, +} from "../../../domain/types/index.js"; +import { ChainError, UsageError } from "../../../domain/errors/index.js"; +import { + gasFreeDigest, + gasFreeTypedData, + normalizeGasFreeSignature, + recoverGasFreeSigner, +} from "../../../domain/gasfree/index.js"; +import { toBaseUnits } from "../../../domain/amounts/index.js"; +import { obtainSignature } from "../../services/signing/obtain-signature.js"; +import type { RecipientResolver } from "../../services/recipient-resolver.js"; + +const MAX_DEADLINE_SECONDS = 86_400n; +const POLL_MS = 1_000; + +export interface GasFreeTransferInput { + to: string; + amount: string; + token: string; + dryRun: boolean; +} + +interface ResolvedGasFreeToken extends GasFreeTokenConfig { + symbol: string; + decimals: number; +} + +export class GasFreeService { + constructor( + private readonly provider: GasFreeProvider, + private readonly gateways: ChainGatewayProvider, + private readonly signers: SignerResolver, + private readonly recipients: RecipientResolver, + private readonly clock: () => number = () => Date.now(), + private readonly sleep: (milliseconds: number) => Promise = ( + milliseconds, + ) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + ) {} + + async info( + scope: TransactionScope, + network: NetworkDescriptor, + ): Promise { + const owner = scope.resolveAddress("tron"); + const gateway = this.gateways.get(network, "tron"); + const [addressInfo, tokens] = await Promise.all([ + this.provider.getAddress(network, owner), + this.#tokens(network), + ]); + if (addressInfo.ownerAddress !== owner) { + throw new ChainError( + "gasfree_integrity", + "GasFree address response owner does not match the selected account", + ); + } + const byAddress = new Map( + tokens.map((token) => [token.tokenAddress, token]), + ); + const views = await Promise.all( + addressInfo.assets.map(async (asset) => { + const token = byAddress.get(asset.tokenAddress); + if ( + !token + || token.activateFee !== asset.activateFee + || token.transferFee !== asset.transferFee + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree token and address fee metadata disagree", + ); + } + return { + symbol: token.symbol, + address: token.tokenAddress, + decimals: token.decimals, + activateFee: token.activateFee, + transferFee: token.transferFee, + balance: await gateway.getTrc20Balance( + token.tokenAddress, + addressInfo.gasFreeAddress, + ), + }; + }), + ); + return { + ownerAddress: owner, + gasFreeAddress: addressInfo.gasFreeAddress, + active: addressInfo.active, + nonce: addressInfo.nonce, + tokens: views, + }; + } + + async transfer( + scope: TransactionScope, + network: NetworkDescriptor, + input: GasFreeTransferInput, + ): Promise { + if (input.dryRun && scope.wait) { + throw new UsageError( + "invalid_option", + "--wait cannot be used with --dry-run", + ); + } + if (!input.dryRun) { + this.signers.assertCanSign(scope.activeAccount, "tron"); + } + const metadata = network.gasfree; + if (!metadata) { + throw new UsageError( + "unsupported_network", + `network ${network.id} does not support GasFree`, + ); + } + const owner = scope.resolveAddress("tron"); + const recipient = this.recipients.resolve("tron", input.to); + const [addressInfo, tokens, providers] = await Promise.all([ + this.provider.getAddress(network, owner), + this.#tokens(network), + this.provider.listProviders(network), + ]); + if (addressInfo.ownerAddress !== owner) { + throw new ChainError( + "gasfree_integrity", + "GasFree address response owner does not match the selected account", + ); + } + const token = selectToken(tokens, input.token); + const provider = selectProvider(providers); + const duration = BigInt(provider.defaultDeadlineDuration); + if (duration <= 0n || duration > MAX_DEADLINE_SECONDS) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider deadline duration is outside the accepted range", + ); + } + const asset = selectAsset(addressInfo, token); + const value = toBaseUnits(input.amount, token.decimals, token.symbol); + if (BigInt(value) <= 0n) { + throw new UsageError( + "invalid_amount", + "--amount must be greater than zero", + ); + } + const activateFee = addressInfo.active ? "0" : asset.activateFee; + // Java signs the upper bound activateFee + transferFee even after activation. + const authorizedMaxFee = add(asset.activateFee, asset.transferFee); + const totalDeducted = add(value, activateFee, asset.transferFee); + const gateway = this.gateways.get(network, "tron"); + const balance = await gateway.getTrc20Balance( + token.tokenAddress, + addressInfo.gasFreeAddress, + ); + if (BigInt(balance) < BigInt(totalDeducted)) { + throw new ChainError( + "insufficient_token_balance", + "GasFree token balance is below transfer amount plus fees", + { balance, required: totalDeducted }, + ); + } + const authorization: GasFreeAuthorization = { + token: token.tokenAddress, + serviceProvider: provider.address, + user: owner, + receiver: recipient.address, + value, + maxFee: authorizedMaxFee, + deadline: String(BigInt(Math.floor(this.clock() / 1000)) + duration), + version: "1", + nonce: addressInfo.nonce, + }; + const base: GasFreeTransferView = { + ...transferView( + "dry-run", + authorization, + token, + addressInfo, + activateFee, + totalDeducted, + ), + ...(recipient.contactName + ? { toContact: recipient.contactName } + : {}), + }; + if (input.dryRun) return base; + if (addressInfo.allowSubmit === false) { + throw new ChainError( + "gasfree_rejected", + "GasFree address is not currently allowed to submit transfers", + ); + } + + const signer = this.signers.resolve(scope.activeAccount, "tron"); + if (signer.address !== owner) { + throw new UsageError( + "invalid_account", + "resolved signer address changed during GasFree construction", + ); + } + const domain = { + controllerChainId: metadata.controllerChainId, + verifyingContract: metadata.verifyingContract, + }; + const expectedDigest = gasFreeDigest(domain, authorization); + const signed = await obtainSignature( + signer, + scope, + (options) => signer.signTypedData( + gasFreeTypedData(domain, authorization), + options, + ), + ); + const reportedDigest = signed.digest.replace(/^0x/i, "").toLowerCase(); + if ( + !/^[0-9a-f]{64}$/.test(reportedDigest) + || reportedDigest !== bytesToHex(expectedDigest) + || signed.primaryType !== "PermitTransfer" + ) { + throw new ChainError( + "signing_rejected", + "GasFree signer did not sign the expected PermitTransfer digest", + ); + } + let signature: string; + let recovered: string; + try { + signature = normalizeGasFreeSignature(signed.signature); + recovered = recoverGasFreeSigner(expectedDigest, signature); + } catch { + throw new ChainError( + "signing_rejected", + "GasFree signer returned an invalid signature", + ); + } + if (recovered !== owner) { + throw new ChainError( + "signing_rejected", + "GasFree signature does not recover to the selected account", + ); + } + + const submitted = await this.provider.submitTransfer(network, { + ...authorization, + sig: signature, + }); + assertAccepted(submitted, authorization, addressInfo.gasFreeAddress); + const accepted: GasFreeTransferView = { + ...base, + stage: "submitted", + traceId: submitted.id, + state: submitted.state, + }; + if (!scope.wait) return accepted; + const terminal = await this.#wait( + network, + submitted, + authorization, + addressInfo.gasFreeAddress, + scope.waitTimeoutMs, + ); + if (!terminal) { + scope.warn( + `--wait: GasFree transfer ${submitted.id} did not finish within ${scope.waitTimeoutMs}ms; returning submitted`, + ); + return accepted; + } + const settledAmount = terminal.txnAmount ?? accepted.amount; + const settledServiceFee = + terminal.txnTransferFee ?? accepted.serviceFee; + const settledActivateFee = + terminal.txnActivateFee ?? accepted.activateFee; + return { + ...accepted, + stage: terminal.state === "SUCCEED" ? "confirmed" : "failed", + state: terminal.state, + amount: settledAmount, + serviceFee: settledServiceFee, + activateFee: settledActivateFee, + totalDeducted: + terminal.txnTotalCost + ?? add(settledAmount, settledServiceFee, settledActivateFee), + ...(terminal.txnHash ? { txId: terminal.txnHash } : {}), + ...(terminal.failureReason + ? { failureReason: terminal.failureReason } + : {}), + }; + } + + async trace( + network: NetworkDescriptor, + traceId: string, + ): Promise { + const record = await this.provider.trace(network, traceId); + assertFeeIntegrity(record, record.maxFee); + const token = (await this.#tokens(network)).find( + (item) => item.tokenAddress === record.tokenAddress, + ); + if (!token) { + throw new ChainError( + "gasfree_integrity", + "trace token is not in the current GasFree token configuration", + ); + } + const serviceFee = + record.txnTransferFee ?? record.estimatedTransferFee ?? "0"; + const activateFee = + record.txnActivateFee ?? record.estimatedActivateFee ?? "0"; + return { + traceId: record.id, + state: record.state, + ...(record.txnHash ? { txId: record.txnHash } : {}), + token: token.symbol, + tokenAddress: token.tokenAddress, + decimals: token.decimals, + amount: record.txnAmount ?? record.amount, + serviceFee, + activateFee, + totalDeducted: + record.txnTotalCost + ?? record.estimatedTotalCost + ?? add(record.amount, serviceFee, activateFee), + from: record.gasFreeAddress, + owner: record.accountAddress, + to: record.targetAddress, + nonce: record.nonce, + ...(record.failureReason + ? { failureReason: record.failureReason } + : {}), + }; + } + + async #tokens( + network: NetworkDescriptor, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + const tokens = await this.provider.listTokens(network); + return Promise.all( + tokens.map(async (token) => { + const info = await gateway.getTokenInfo(token.tokenAddress); + const symbol = + typeof info.symbol === "string" ? info.symbol : undefined; + const decimals = info.decimals; + if ( + !symbol + || decimals === undefined + || !Number.isInteger(decimals) + || decimals < 0 + || decimals > 255 + ) { + throw new ChainError( + "gasfree_integrity", + `token metadata is incomplete for ${token.tokenAddress}`, + ); + } + if ( + (token.symbol !== undefined && token.symbol !== symbol) + || (token.decimals !== undefined && token.decimals !== decimals) + ) { + throw new ChainError( + "gasfree_integrity", + `provider token metadata disagrees with the TRC20 contract for ${token.tokenAddress}`, + ); + } + return { ...token, symbol, decimals }; + }), + ); + } + + async #wait( + network: NetworkDescriptor, + initial: GasFreeTransferRecord, + authorization: GasFreeAuthorization, + gasFreeAddress: string, + timeoutMs: number, + ): Promise { + const deadline = this.clock() + Math.max(0, timeoutMs); + let previous = initial.state; + if (previous === "SUCCEED" || previous === "FAILED") return initial; + for (;;) { + const remaining = deadline - this.clock(); + if (remaining <= 0) return undefined; + await this.sleep(Math.min(POLL_MS, remaining)); + const current = await this.provider.trace(network, initial.id); + if (current.id !== initial.id) { + throw new ChainError( + "gasfree_integrity", + "GasFree trace id changed while polling", + ); + } + assertAccepted(current, authorization, gasFreeAddress); + if (stateRank(current.state) < stateRank(previous)) { + throw new ChainError( + "gasfree_integrity", + "GasFree state regressed while polling", + ); + } + previous = current.state; + if (current.state === "SUCCEED" || current.state === "FAILED") { + return current; + } + } + } +} + +function selectToken( + tokens: ResolvedGasFreeToken[], + symbol: string, +): ResolvedGasFreeToken { + const matches = tokens.filter( + (token) => token.symbol.toLowerCase() === symbol.toLowerCase(), + ); + if (matches.length === 0) { + throw new UsageError( + "unsupported_token", + `GasFree provider does not support token: ${symbol}`, + ); + } + if (matches.length > 1) { + throw new ChainError( + "gasfree_integrity", + `GasFree token symbol is ambiguous: ${symbol}`, + ); + } + return matches[0]!; +} + +/** Java selects the first provider returned by the authenticated configuration endpoint. */ +function selectProvider( + providers: GasFreeProviderConfig[], +): GasFreeProviderConfig { + if (providers.length === 0) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider configuration is empty", + ); + } + return providers[0]!; +} + +function selectAsset( + address: GasFreeAddressInfo, + token: ResolvedGasFreeToken, +) { + const matches = address.assets.filter( + (asset) => asset.tokenAddress === token.tokenAddress, + ); + if (matches.length !== 1) { + throw new ChainError( + "gasfree_integrity", + "GasFree address asset configuration is missing or ambiguous", + ); + } + const asset = matches[0]!; + if ( + asset.activateFee !== token.activateFee + || asset.transferFee !== token.transferFee + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree fee metadata disagrees across endpoints", + ); + } + return asset; +} + +function transferView( + stage: GasFreeTransferView["stage"], + authorization: GasFreeAuthorization, + token: ResolvedGasFreeToken, + address: GasFreeAddressInfo, + activateFee: string, + totalDeducted: string, +): GasFreeTransferView { + return { + kind: "gasfree-transfer", + stage, + token: token.symbol, + tokenAddress: token.tokenAddress, + decimals: token.decimals, + amount: authorization.value, + serviceFee: token.transferFee, + activateFee, + authorizedMaxFee: authorization.maxFee, + totalDeducted, + owner: authorization.user, + from: address.gasFreeAddress, + to: authorization.receiver, + serviceProvider: authorization.serviceProvider, + nonce: authorization.nonce, + deadline: authorization.deadline, + }; +} + +function assertAccepted( + record: GasFreeTransferRecord, + authorization: GasFreeAuthorization, + gasFreeAddress: string, +): void { + const deadlineMatches = + record.expiredAt === undefined + || record.expiredAt === authorization.deadline + || record.expiredAt === `${authorization.deadline}000`; + if ( + record.tokenAddress !== authorization.token + || record.providerAddress !== authorization.serviceProvider + || record.accountAddress !== authorization.user + || record.targetAddress !== authorization.receiver + || record.gasFreeAddress !== gasFreeAddress + || record.amount !== authorization.value + || record.nonce !== authorization.nonce + || (record.maxFee !== undefined + && record.maxFee !== authorization.maxFee) + || !deadlineMatches + || (record.txnAmount !== undefined + && record.txnAmount !== authorization.value) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree submit receipt does not match the signed authorization", + ); + } + assertFeeIntegrity(record, authorization.maxFee); +} + +/** Provider estimates and final charges are untrusted until bounded by signed maxFee. */ +function assertFeeIntegrity( + record: GasFreeTransferRecord, + authorizedMaxFee?: string, +): void { + const feeSets = [ + [ + record.estimatedTransferFee, + record.estimatedActivateFee, + record.estimatedTotalFee, + record.estimatedTotalCost, + ], + [ + record.txnTransferFee, + record.txnActivateFee, + record.txnTotalFee, + record.txnTotalCost, + ], + ] as const; + for (const [transfer, activate, totalFee, totalCost] of feeSets) { + const componentTotal = + transfer !== undefined && activate !== undefined + ? BigInt(transfer) + BigInt(activate) + : undefined; + const observedTotal = + totalFee === undefined ? componentTotal : BigInt(totalFee); + if ( + observedTotal !== undefined + && authorizedMaxFee !== undefined + && observedTotal > BigInt(authorizedMaxFee) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider fee exceeds the signed maxFee", + ); + } + if ( + componentTotal !== undefined + && totalFee !== undefined + && componentTotal !== BigInt(totalFee) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider fee components do not match the total fee", + ); + } + if ( + observedTotal !== undefined + && totalCost !== undefined + && BigInt(record.amount) + observedTotal !== BigInt(totalCost) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider total cost does not match amount plus fees", + ); + } + } +} + +function add(...values: string[]): string { + return values + .reduce((sum, value) => sum + BigInt(value), 0n) + .toString(); +} + +function stateRank(state: GasFreeTransferRecord["state"]): number { + return { + WAITING: 0, + INPROGRESS: 1, + CONFIRMING: 2, + SUCCEED: 3, + FAILED: 3, + }[state]; +} diff --git a/ts/src/application/use-cases/tron/multisig-authorization.test.ts b/ts/src/application/use-cases/tron/multisig-authorization.test.ts new file mode 100644 index 000000000..cb85c07d9 --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-authorization.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TronTransactionArtifact } from "../../../domain/types/index.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import { + assertTronSignerAuthorized, + authorizationState, + tronTransactionHooks, +} from "./multisig-authorization.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +function transaction(permissionId = 2): TronTransactionArtifact { + return { + txID: "ab".repeat(32), + raw_data_hex: "00", + raw_data: { + expiration: 2_000_000_000_000, + contract: [{ type: "TransferContract", Permission_id: permissionId }], + }, + }; +} + +function gateway(over: Partial = {}): TronGateway { + return { + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "finance", + threshold: 2, + operationsHex: "02" + "00".repeat(31), + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + getApprovedList: vi.fn(async () => []), + ...over, + } as unknown as TronGateway; +} + +describe("TRON multisig authorization preflight", () => { + it("does not attach approval RPC checks to ordinary transaction commands", () => { + const hooks = tronTransactionHooks({ + prepareTransaction: vi.fn((value) => value), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway); + expect("preflight" in hooks).toBe(false); + }); + + it("accepts an unused key whose active bitmap allows the transaction contract", async () => { + await expect(assertTronSignerAuthorized(gateway(), transaction(), A, 1_900_000_000_000)) + .resolves.toBeUndefined(); + }); + + it("rejects a signer outside the selected permission and a repeated signer", async () => { + await expect(assertTronSignerAuthorized(gateway(), transaction(), "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", 1_900_000_000_000)) + .rejects.toMatchObject({ code: "not_authorized" }); + + const signed = gateway({ + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "finance", + threshold: 2, + operationsHex: "02" + "00".repeat(31), + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: [A], + currentWeight: 1, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + getApprovedList: vi.fn(async () => [A]), + }); + await expect(assertTronSignerAuthorized(signed, transaction(), A, 1_900_000_000_000)) + .rejects.toMatchObject({ code: "already_signed" }); + }); + + it("fails closed on a disallowed operation and inconsistent node approval state", async () => { + const disallowed = gateway({ + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "vote", + threshold: 1, + operationsHex: "10" + "00".repeat(31), + keys: [{ address: A, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + }); + await expect(assertTronSignerAuthorized(disallowed, transaction(), A, 1_900_000_000_000)) + .rejects.toMatchObject({ code: "not_authorized" }); + + const inconsistent = gateway({ + getApprovedList: vi.fn(async () => [A]), + }); + await expect(authorizationState(inconsistent, transaction())) + .rejects.toMatchObject({ code: "provider_error" }); + }); + + it("rejects expired transactions before calling the node", async () => { + const target = gateway(); + await expect(assertTronSignerAuthorized(target, transaction(), A, 2_100_000_000_000)) + .rejects.toMatchObject({ code: "tx_expired" }); + expect(target.getSignWeight).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/multisig-authorization.ts b/ts/src/application/use-cases/tron/multisig-authorization.ts new file mode 100644 index 000000000..1cc0e7896 --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-authorization.ts @@ -0,0 +1,116 @@ +import type { TronTransactionArtifact, UnsignedTx } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { decodeOperations } from "../../../domain/permission/index.js"; +import { addressCodec } from "../../../domain/family/index.js"; +import type { TronGateway, TronSignWeight } from "../../ports/chain/tron-gateway.js"; +import { assertNotExpired, transactionContract } from "./transaction-artifact.js"; + +function uniqueAddresses(addresses: readonly string[], field: string): Set { + const set = new Set(addresses); + if (set.size !== addresses.length + || addresses.some((address) => !addressCodec("tron").validate(address))) { + throw new ChainError("provider_error", `TRON node returned invalid or duplicate addresses in ${field}`); + } + return set; +} + +function assertPermissionConsistent( + transaction: TronTransactionArtifact, + weight: TronSignWeight, + approved: string[], +): NonNullable { + const acceptedCodes = new Set(["ENOUGH_PERMISSION", "NOT_ENOUGH_PERMISSION"]); + if (weight.resultCode && !acceptedCodes.has(weight.resultCode)) { + throw new ChainError( + weight.resultCode.includes("PERMISSION") ? "not_authorized" : "invalid_transaction", + weight.message || `node rejected transaction signatures (${weight.resultCode})`, + ); + } + if (!weight.permission) { + const code = weight.resultCode || "unknown"; + throw new ChainError( + code.includes("PERMISSION") ? "not_authorized" : "invalid_transaction", + weight.message || `node could not resolve transaction permission (${code})`, + ); + } + const permissionId = transactionContract(transaction).Permission_id ?? 0; + if (weight.permission.id !== permissionId) { + throw new ChainError("provider_error", "node resolved a different permission id for the transaction"); + } + const fromWeight = uniqueAddresses(weight.approvedList, "getsignweight.approved_list"); + const fromApproved = uniqueAddresses(approved, "getapprovedlist.approved_list"); + if (fromWeight.size !== fromApproved.size || [...fromWeight].some((address) => !fromApproved.has(address))) { + throw new ChainError("provider_error", "getsignweight and getapprovedlist returned different approvals"); + } + const keys = new Map(weight.permission.keys.map((key) => [key.address, key.weight])); + let computedWeight = 0; + for (const address of fromApproved) { + const keyWeight = keys.get(address); + if (keyWeight === undefined) { + throw new ChainError("provider_error", "node approved a signer outside the selected permission group"); + } + computedWeight += keyWeight; + } + if (computedWeight !== weight.currentWeight) { + throw new ChainError("provider_error", "node current_weight does not match approved signer weights"); + } + + const contractType = transactionContract(transaction).type; + if (permissionId >= 2) { + if (!weight.permission.operationsHex) { + throw new ChainError("provider_error", "active permission is missing its operations bitmap"); + } + let operations: ReturnType; + try { + operations = decodeOperations(weight.permission.operationsHex); + } catch { + throw new ChainError("provider_error", "active permission has a malformed operations bitmap"); + } + if (!operations.operations.includes(contractType)) { + throw new ChainError("not_authorized", `permission ${permissionId} does not allow ${contractType}`); + } + } + return weight.permission; +} + +export async function authorizationState( + gateway: TronGateway, + transaction: TronTransactionArtifact, +) { + const [weight, approved] = await Promise.all([ + gateway.getSignWeight(transaction), + gateway.getApprovedList(transaction), + ]); + const permission = assertPermissionConsistent(transaction, weight, approved); + return { weight, approved, permission }; +} + +/** Validate permission membership before decrypting a software key or opening Ledger UI. */ +export async function assertTronSignerAuthorized( + gateway: TronGateway, + transaction: UnsignedTx, + signerAddress: string, + now = Date.now(), +): Promise { + const artifact = transaction as TronTransactionArtifact; + assertNotExpired(artifact, now); + const { approved, permission } = await authorizationState(gateway, artifact); + if (!permission.keys.some((key) => key.address === signerAddress)) { + throw new ChainError("not_authorized", "selected signer is not a key in the transaction permission group"); + } + if (approved.includes(signerAddress)) { + throw new ChainError("already_signed", "selected signer has already approved this transaction"); + } +} + +/** + * Shared transaction-construction hooks. Permission approval checks belong to explicit + * multi-signature workflows so ordinary signing does not depend on online approval endpoints. + */ +export function tronTransactionHooks(gateway: TronGateway) { + return { + prepare: (transaction: UnsignedTx, options: { permissionId: number; expiration?: number }) => + gateway.prepareTransaction(transaction, options), + artifact: (transaction: UnsignedTx) => gateway.encodeTransactionHex(transaction), + }; +} diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts new file mode 100644 index 000000000..cd4f4bead --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + TronLinkCollaborationPort, + TronLinkRemoteRecord, +} from "../../ports/tronlink-collaboration.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import { + decodeTransactionHex, + encodeTransactionHex, +} from "../../../adapters/outbound/chain/tron/transaction-codec.js"; +import type { TronMultisigService } from "./multisig-service.js"; +import { TronMultisigCollaborationService } from "./multisig-collaboration-service.js"; + +const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OWNER_HEX = "417445076632894b7b844887d2bcd2e8c30bb6c6f2"; +const TO_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; +const SIG = "ab".repeat(65); +const NOW = 1_900_000_000_000; +const NETWORK = { id: "tron:nile", family: "tron", chainId: "nile" } as never; + +function unsignedHex(): string { + return encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, to_address: TO_HEX, amount: 1 }, + type_url: "type.googleapis.com/protocol.TransferContract", + }, + type: "TransferContract", + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: NOW, + expiration: NOW + 86_400_000, + }, + }); +} + +function remote(hex = unsignedHex(), overrides: Partial = {}): TronLinkRemoteRecord { + const transaction = decodeTransactionHex(hex); + const signed = transaction.signature?.length ?? 0; + return { + hash: transaction.txID, + contract_type: "TransferContract", + state: 0, + is_sign: signed > 0 ? 1 : 0, + current_weight: signed, + threshold: 2, + contract_data: { owner_address: A }, + originator_address: A, + current_transaction: transaction, + signature_progress: [ + { address: A, weight: 1, is_sign: signed > 0 ? 1 : 0, sign_time: signed > 0 ? 1_900_000_001 : 0 }, + { address: B, weight: 1, is_sign: 0, sign_time: 0 }, + ], + ...overrides, + }; +} + +function gateway(): TronGateway { + return { + encodeTransactionHex, + decodeTransactionHex, + decodeTransaction: () => ({ kind: "trx", from: A, to: B, rawAmount: "1" }), + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "finance", + threshold: 2, + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + } as unknown as TronGateway; +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + resolveAddress: () => A, + timeoutMs: 1_000, + wait: false, + waitTimeoutMs: 1_000, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function approval(hex: string) { + const transaction = decodeTransactionHex(hex); + const signatures = transaction.signature?.length ?? 0; + return { + txId: transaction.txID, + contractType: "TransferContract", + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: signatures, + missingWeight: 2 - signatures, + thresholdReached: signatures >= 2, + approved: signatures ? [{ address: A, weight: 1 }] : [], + expiration: transaction.raw_data.expiration!, + expired: false, + signatures, + }; +} + +function setup(record = remote()) { + const collaboration = { + list: vi.fn(async () => ({ total: 1, records: [record] })), + create: vi.fn(async () => {}), + submit: vi.fn(async () => {}), + watch: vi.fn(async (_network, _address, _signal, onMessage) => { + onMessage([{ state: 0, is_sign: 0 }, { state: 0, is_sign: 1 }]); + }), + } as unknown as TronLinkCollaborationPort; + const chain = gateway(); + const provider = { get: vi.fn(() => chain) } as unknown as ChainGatewayProvider; + const signedHex = encodeTransactionHex({ ...decodeTransactionHex(unsignedHex()), signature: [SIG] }); + const local = { + approvals: vi.fn(async (_network, hex: string) => approval(hex)), + signChecked: vi.fn(async () => ({ + kind: "tx-sign", + signer: A, + signerWeight: 1, + hex: signedHex, + checked: true, + transaction: { + txId: decodeTransactionHex(signedHex).txID, + contractType: "TransferContract", + permissionId: 2, + expiration: NOW + 86_400_000, + expired: false, + signatures: 1, + }, + approval: approval(signedHex), + })), + } as unknown as TronMultisigService; + return { + service: new TronMultisigCollaborationService(collaboration, provider, local, () => NOW), + collaboration, + local, + signedHex, + }; +} + +describe("TronLink multi-sign collaboration workflow", () => { + it("projects only byte- and node-validated remote transactions", async () => { + const result = await setup().service.list(NETWORK, A); + expect(result).toMatchObject({ + total: 1, + transactions: [{ + txId: decodeTransactionHex(unsignedHex()).txID, + owner: A, + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: 0, + awaitingMySignature: true, + }], + }); + }); + + it("rejects hash, owner, and progress metadata tampering", async () => { + await expect(setup(remote(unsignedHex(), { hash: "00".repeat(32) })).service.list(NETWORK, A)) + .rejects.toMatchObject({ code: "provider_error" }); + await expect(setup(remote(unsignedHex(), { contract_data: { owner_address: B } })).service.list(NETWORK, A)) + .rejects.toMatchObject({ code: "provider_error" }); + await expect(setup(remote(unsignedHex(), { current_weight: 1 })).service.list(NETWORK, A)) + .rejects.toMatchObject({ code: "provider_error" }); + }); + + it("creates by uploading unsigned raw_data without invoking a signer", async () => { + const { service, collaboration, local } = setup(); + const result = await service.create(NETWORK, A, unsignedHex()); + expect(result).toMatchObject({ action: "create", accepted: true, transaction: { currentWeight: 0 } }); + expect(local.signChecked).not.toHaveBeenCalled(); + const request = vi.mocked(collaboration.create).mock.calls[0]![2]; + expect(request).toMatchObject({ + permissionName: "finance", + txId: decodeTransactionHex(unsignedHex()).txID, + contractType: "TransferContract", + }); + expect(JSON.parse(request.rawDataJson)).toMatchObject({ + contract: [{ parameter: { value: { owner_address: A, to_address: B } } }], + }); + }); + + it("refuses create artifacts that already contain a signature", async () => { + const { service, collaboration, local, signedHex } = setup(); + await expect(service.create(NETWORK, A, signedHex)) + .rejects.toMatchObject({ code: "invalid_value" }); + expect(local.signChecked).not.toHaveBeenCalled(); + expect(collaboration.create).not.toHaveBeenCalled(); + }); + + it("fetches the accumulated transaction, adds one signature, and submits it", async () => { + const { service, collaboration, local, signedHex } = setup(); + const txId = decodeTransactionHex(unsignedHex()).txID; + const context = scope(); + const result = await service.sign(context, NETWORK, txId); + expect(result).toMatchObject({ action: "sign", accepted: true, hex: signedHex }); + expect(local.signChecked).toHaveBeenCalledWith(context, NETWORK, unsignedHex()); + expect(collaboration.submit).toHaveBeenCalledTimes(1); + }); + + it("counts only pending unsigned WebSocket records", async () => { + const { service } = setup(); + const counts: number[] = []; + const result = await service.watch( + NETWORK, + A, + new AbortController().signal, + (count) => counts.push(count), + ); + expect(counts).toEqual([1]); + expect(result).toMatchObject({ action: "watch", notifications: 1 }); + }); +}); diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts new file mode 100644 index 000000000..3cc22f7ba --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts @@ -0,0 +1,418 @@ +import type { + TronLinkCollaborationPort, + TronLinkRemoteRecord, +} from "../../ports/tronlink-collaboration.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { + NetworkDescriptor, + TronLinkMultisigListView, + TronLinkMultisigState, + TronLinkMultisigTransactionView, + TronLinkSignatureProgressView, + TronTransactionArtifact, + TxApprovalView, +} from "../../../domain/types/index.js"; +import { ChainError, UsageError } from "../../../domain/errors/index.js"; +import { TronAddress, tronHexToBase58 } from "../../../domain/address/index.js"; +import { TronMultisigService } from "./multisig-service.js"; + +const LIST_LIMIT = 20; +const MAX_LOOKUP_RECORDS = 1000; +const LOOKUP_PAGE_SIZE = 50; + +interface ValidatedRemoteRecord { + view: TronLinkMultisigTransactionView; + hex: string; +} + +/** + * Multi-signature collaboration use case. TronLink is an external coordination port, + * never the source of truth for transaction bytes or on-chain approval state. + */ +export class TronMultisigCollaborationService { + readonly #address = new TronAddress(); + + constructor( + private readonly collaboration: TronLinkCollaborationPort, + private readonly gateways: ChainGatewayProvider, + private readonly multisig: TronMultisigService, + private readonly now: () => number = () => Date.now(), + ) {} + + async list(network: NetworkDescriptor, address: string): Promise { + const page = await this.collaboration.list(network, address, { + state: 255, + start: 0, + limit: LIST_LIMIT, + }); + const gateway = this.gateways.get(network, "tron"); + const validated = page.records.map((record) => this.#validate(gateway, address, record)); + await mapWithConcurrency(validated, 4, (transaction) => this.#verifyOnChain(network, transaction)); + return { + address, + total: page.total, + transactions: validated.map((transaction) => transaction.view), + }; + } + + async create( + network: NetworkDescriptor, + address: string, + unsignedHex: string, + ) { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(unsignedHex); + if ((transaction.signature?.length ?? 0) !== 0) { + throw new UsageError("invalid_value", "TronLink --create requires an unsigned transaction"); + } + const approval = await this.multisig.approvals(network, unsignedHex); + if (approval.expired) throw new ChainError("tx_expired", "transaction has expired"); + + const weight = await gateway.getSignWeight(transaction); + const permission = weight.permission; + if (!permission + || permission.id !== approval.permission.id + || !permission.keys.some((key) => key.address === address)) { + throw new ChainError("not_authorized", "selected account is not a key in the transaction permission"); + } + + const visible = visibleTransaction(gateway, unsignedHex); + await this.collaboration.create(network, address, { + permissionName: permission.name, + txId: approval.txId, + rawDataJson: JSON.stringify(visible.raw_data), + contractType: approval.contractType, + }); + return { + action: "create" as const, + accepted: true as const, + hex: unsignedHex.trim().replace(/^0x/i, "").toLowerCase(), + transaction: approval, + }; + } + + async sign( + scope: TransactionScope, + network: NetworkDescriptor, + txId: string, + ) { + const address = scope.resolveAddress("tron"); + const remote = await this.#find(network, address, normalizeTxId(txId)); + if (remote.view.state !== "pending") { + if (remote.view.signedByCurrentAccount) { + throw new ChainError("already_signed", "this account has already signed the TronLink transaction"); + } + throw new ChainError("invalid_value", `TronLink transaction is ${remote.view.state}, not pending`); + } + if (remote.view.expired) throw new ChainError("tx_expired", "transaction has expired"); + + const signed = await this.multisig.signChecked(scope, network, remote.hex); + const gateway = this.gateways.get(network, "tron"); + await this.collaboration.submit(network, signed.signer, visibleTransaction(gateway, signed.hex)); + return { + action: "sign" as const, + accepted: true as const, + signer: signed.signer, + signerWeight: signed.signerWeight, + hex: signed.hex, + transaction: signed.approval, + }; + } + + async watch( + network: NetworkDescriptor, + address: string, + signal: AbortSignal, + onPending: (count: number) => void, + ) { + let notifications = 0; + await this.collaboration.watch(network, address, signal, (payload) => { + if (!Array.isArray(payload)) return; + const count = payload.filter((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const record = entry as Record; + return booleanFlag(record.is_sign, "is_sign") === false + && safeInteger(record.state, "state", 0) === 0; + }).length; + if (count > 0) { + notifications += 1; + onPending(count); + } + }); + return { action: "watch" as const, address, notifications }; + } + + async #find( + network: NetworkDescriptor, + address: string, + txId: string, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + let start = 0; + let total = Number.MAX_SAFE_INTEGER; + while (start < total && start < MAX_LOOKUP_RECORDS) { + const page = await this.collaboration.list(network, address, { + state: 255, + start, + limit: LOOKUP_PAGE_SIZE, + }); + total = page.total; + for (const record of page.records) { + const validated = this.#validate(gateway, address, record); + if (validated.view.txId === txId) { + await this.#verifyOnChain(network, validated); + return validated; + } + } + if (page.records.length === 0) break; + start += page.records.length; + } + if (total > MAX_LOOKUP_RECORDS) { + throw new ChainError( + "not_found", + `transaction was not found in the newest ${MAX_LOOKUP_RECORDS} TronLink records`, + ); + } + throw new ChainError("not_found", `TronLink transaction not found: ${txId}`); + } + + #validate( + gateway: TronGateway, + currentAddress: string, + remote: TronLinkRemoteRecord, + ): ValidatedRemoteRecord { + const hash = normalizeTxId(stringValue(remote.hash, "hash")); + let hex: string; + let transaction: TronTransactionArtifact; + try { + hex = gateway.encodeTransactionHex(remote.current_transaction); + transaction = gateway.decodeTransactionHex(hex); + } catch { + throw new ChainError("provider_error", "TronLink returned a transaction that is not losslessly encodable"); + } + if (transaction.txID !== hash) { + throw new ChainError("provider_error", "TronLink record hash does not match transaction raw_data"); + } + + const contract = transaction.raw_data.contract[0]; + const contractType = stringValue(remote.contract_type, "contract_type"); + if (!contract || contract.type !== contractType) { + throw new ChainError("provider_error", "TronLink contract_type does not match transaction raw_data"); + } + const txOwner = normalizedAddress( + contract.parameter?.value?.owner_address, + "transaction owner_address", + this.#address, + ); + const contractData = objectValue(remote.contract_data, "contract_data"); + const metadataOwner = normalizedAddress( + contractData.owner_address, + "contract_data.owner_address", + this.#address, + ); + if (metadataOwner !== txOwner) { + throw new ChainError("provider_error", "TronLink owner metadata does not match transaction raw_data"); + } + + const originator = normalizedAddress(remote.originator_address, "originator_address", this.#address); + const stateCode = safeInteger(remote.state, "state", 0); + const isSigned = booleanFlag(remote.is_sign, "is_sign"); + const state = recordState(stateCode, isSigned); + const currentWeight = safeInteger(remote.current_weight, "current_weight", 0); + const threshold = safeInteger(remote.threshold, "threshold", 1); + const signatureProgress = progress(remote.signature_progress, this.#address); + const currentProgress = signatureProgress.find((item) => item.address === currentAddress); + if (!currentProgress) { + throw new ChainError("not_authorized", "selected account is not a signer in this TronLink transaction"); + } + if (currentProgress.signed !== isSigned) { + throw new ChainError("provider_error", "TronLink is_sign disagrees with signature_progress"); + } + const signedWeight = signatureProgress + .filter((item) => item.signed) + .reduce((sum, item) => sum + item.weight, 0); + if (!Number.isSafeInteger(signedWeight) || signedWeight !== currentWeight) { + throw new ChainError("provider_error", "TronLink current_weight disagrees with signature_progress"); + } + const signatures = transaction.signature?.length ?? 0; + if (signatureProgress.filter((item) => item.signed).length !== signatures) { + throw new ChainError("provider_error", "TronLink signature_progress disagrees with transaction signatures"); + } + + const createdAt = safeInteger(transaction.raw_data.timestamp, "transaction timestamp", 0); + const expiration = safeInteger(transaction.raw_data.expiration, "transaction expiration", 1); + const decoded = gateway.decodeTransaction(transaction); + return { + hex, + view: { + txId: hash, + state, + contractType, + originator, + owner: txOwner, + permission: { + id: contract.Permission_id ?? 0, + name: "", + threshold, + }, + currentWeight, + missingWeight: Math.max(0, threshold - currentWeight), + thresholdReached: currentWeight >= threshold, + awaitingMySignature: stateCode === 0 && !isSigned, + signedByCurrentAccount: isSigned, + createdAt, + expiration, + expired: expiration <= this.now(), + signatures, + signatureProgress, + from: decoded.from, + to: decoded.to, + rawAmount: decoded.rawAmount, + }, + }; + } + + async #verifyOnChain(network: NetworkDescriptor, remote: ValidatedRemoteRecord): Promise { + const approval = await this.multisig.approvals(network, remote.hex); + const signedProgress = remote.view.signatureProgress + .filter((entry) => entry.signed) + .map((entry) => entry.address); + const approved = new Set(approval.approved.map((entry) => entry.address)); + if ( + approval.txId !== remote.view.txId + || approval.contractType !== remote.view.contractType + || approval.currentWeight !== remote.view.currentWeight + || approval.permission.id !== remote.view.permission.id + || approval.permission.threshold !== remote.view.permission.threshold + || approval.signatures !== remote.view.signatures + || approval.expiration !== remote.view.expiration + || approved.size !== signedProgress.length + || signedProgress.some((address) => !approved.has(address)) + ) { + throw new ChainError( + "provider_error", + "TronLink transaction metadata or signatures disagree with the selected network", + ); + } + remote.view.permission.name = approval.permission.name; + } +} + +/** Convert address fields to visible=true JSON, then prove that protobuf bytes are unchanged. */ +function visibleTransaction(gateway: TronGateway, hex: string): TronTransactionArtifact { + const transaction = structuredClone(gateway.decodeTransactionHex(hex)); + convertAddressFields(transaction); + transaction.visible = true; + if (gateway.encodeTransactionHex(transaction) !== hex.trim().replace(/^0x/i, "").toLowerCase()) { + throw new ChainError("provider_error", "visible transaction projection changed transaction bytes"); + } + return transaction; +} + +function convertAddressFields(value: unknown): void { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) convertAddressFields(item); + return; + } + for (const [field, item] of Object.entries(value as Record)) { + if (typeof item === "string" && (field === "address" || field.endsWith("_address"))) { + (value as Record)[field] = tronHexToBase58(item); + } else { + convertAddressFields(item); + } + } +} + +function progress(value: unknown, addressCodec: TronAddress): TronLinkSignatureProgressView[] { + if (!Array.isArray(value)) { + throw new ChainError("provider_error", "TronLink signature_progress must be an array"); + } + const addresses = new Set(); + return value.map((entry, index) => { + const item = objectValue(entry, `signature_progress[${index}]`); + const address = normalizedAddress(item.address, `signature_progress[${index}].address`, addressCodec); + if (addresses.has(address)) { + throw new ChainError("provider_error", "TronLink signature_progress contains duplicate addresses"); + } + addresses.add(address); + const weight = safeInteger(item.weight, `signature_progress[${index}].weight`, 1); + const signed = booleanFlag(item.is_sign, `signature_progress[${index}].is_sign`); + const signTime = safeInteger(item.sign_time ?? 0, `signature_progress[${index}].sign_time`, 0); + const signedAt = signTime === 0 ? null : signTime * 1000; + if (!Number.isSafeInteger(signedAt ?? 0) || (!signed && signTime !== 0)) { + throw new ChainError("provider_error", "TronLink signature timestamp is inconsistent"); + } + return { address, weight, signed, signedAt }; + }); +} + +function recordState(code: number, signed: boolean): TronLinkMultisigState { + if (code === 0) return signed ? "signed" : "pending"; + if (code === 1) return "success"; + if (code === 2) return "failed"; + throw new ChainError("provider_error", `TronLink returned unsupported transaction state ${code}`); +} + +function normalizeTxId(value: string): string { + const normalized = value.replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new UsageError("invalid_value", "TronLink txId must be a 32-byte hex string"); + } + return normalized; +} + +function normalizedAddress(value: unknown, field: string, codec: TronAddress): string { + const normalized = tronHexToBase58(stringValue(value, field)); + if (!codec.validate(normalized)) { + throw new ChainError("provider_error", `TronLink ${field} is not a valid TRON address`); + } + return normalized; +} + +function objectValue(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ChainError("provider_error", `TronLink ${field} must be an object`); + } + return value as Record; +} + +function stringValue(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > 256) { + throw new ChainError("provider_error", `TronLink ${field} must be a non-empty string`); + } + return value; +} + +function safeInteger(value: unknown, field: string, minimum: number): number { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= minimum) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed) && parsed >= minimum) return parsed; + } + throw new ChainError("provider_error", `TronLink ${field} must be a safe integer`); +} + +function booleanFlag(value: unknown, field: string): boolean { + if (value === true || value === 1 || value === "1" || value === "true") return true; + if (value === false || value === 0 || value === "0" || value === "false") return false; + throw new ChainError("provider_error", `TronLink ${field} must be a boolean flag`); +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + operation: (value: T) => Promise, +): Promise { + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (next < values.length) { + const index = next; + next += 1; + await operation(values[index]!); + } + }); + await Promise.all(workers); +} diff --git a/ts/src/application/use-cases/tron/multisig-service.test.ts b/ts/src/application/use-cases/tron/multisig-service.test.ts new file mode 100644 index 000000000..092ed13ba --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-service.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Signer } from "../../../domain/types/index.js"; +import { encodeOperations } from "../../../domain/permission/index.js"; +import type { TronGateway, TronSignWeight } from "../../ports/chain/tron-gateway.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import { + decodeTransactionHex, + encodeTransactionHex, +} from "../../../adapters/outbound/chain/tron/transaction-codec.js"; +import { TronMultisigService } from "./multisig-service.js"; +import { TronSigService } from "./sig-service.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OWNER_HEX = "417445076632894b7b844887d2bcd2e8c30bb6c6f2"; +const TO_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; +const SIG_A = "ab".repeat(65); +const SIG_B = "cd".repeat(65); +const NOW = 1_900_000_000_000; + +function unsignedHex(expiration = NOW + 60_000): string { + return encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, to_address: TO_HEX, amount: 1 }, + type_url: "type.googleapis.com/protocol.TransferContract", + }, + type: "TransferContract", + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: NOW, + expiration, + }, + }); +} + +function fakeGateway(overrides: Partial = {}): TronGateway { + const approvals = (transaction: unknown) => { + const count = (transaction as { signature?: string[] }).signature?.length ?? 0; + return count === 0 ? [] : count === 1 ? [A] : [A, B]; + }; + return { + decodeTransactionHex, + encodeTransactionHex, + decodeTransaction: () => ({ kind: "trx", from: A, to: B, rawAmount: "1" }), + getSignWeight: vi.fn(async (transaction): Promise => ({ + permission: { + id: 2, + name: "active", + threshold: 2, + operationsHex: encodeOperations(["TransferContract"]), + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: approvals(transaction), + currentWeight: approvals(transaction).length, + resultCode: approvals(transaction).length >= 2 ? "ENOUGH_PERMISSION" : "NOT_ENOUGH_PERMISSION", + })), + getApprovedList: vi.fn(async (transaction) => approvals(transaction)), + getMultiSignFee: vi.fn(async () => 1_000_000), + broadcastHex: vi.fn(async () => ({ txId: "txid" })), + getTransactionInfoById: vi.fn(async () => ({})), + ...overrides, + } as unknown as TronGateway; +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + resolveAddress: () => A, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function service(gateway: TronGateway, signer?: Signer) { + const actualSigner: Signer = signer ?? { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => { + const mutable = transaction as { signature?: string[] }; + mutable.signature = [...(mutable.signature ?? []), SIG_A]; + return transaction; + }), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => actualSigner), + } as unknown as SignerResolver; + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + const signing = new TronSigService(provider, signers, () => NOW); + return new TronMultisigService(provider, signing, () => NOW); +} + +const NETWORK = { id: "tron:nile", family: "tron" } as never; + +describe("local TRON multi-signature workflow", () => { + it("reports structured permission and missing weight for an unsigned transaction", async () => { + const view = await service(fakeGateway()).approvals(NETWORK, unsignedHex()); + expect(view).toMatchObject({ + permission: { id: 2, name: "active", threshold: 2 }, + currentWeight: 0, + missingWeight: 2, + thresholdReached: false, + expired: false, + contractType: "TransferContract", + approved: [], + }); + }); + + it("appends exactly one signature while preserving txID/raw_data and verifies its weight", async () => { + const before = decodeTransactionHex(unsignedHex()); + const signed = await service(fakeGateway()).signChecked(scope(), NETWORK, unsignedHex()); + const after = decodeTransactionHex(signed.hex); + expect(after.txID).toBe(before.txID); + expect(after.raw_data_hex).toBe(before.raw_data_hex); + expect(after.signature).toEqual([SIG_A]); + expect(signed).toMatchObject({ + signer: A, + signerWeight: 1, + checked: true, + approval: { currentWeight: 1, missingWeight: 1 }, + }); + }); + + it("rejects expiration before calling a signer", async () => { + const signer: Signer = { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => transaction), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + await expect(service(fakeGateway(), signer).signChecked(scope(), NETWORK, unsignedHex(NOW))) + .rejects.toMatchObject({ code: "tx_expired" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("does not sign when the authorized account and resolved signer diverge", async () => { + const signer: Signer = { + kind: "software", + address: B, + sign: vi.fn(async (transaction) => transaction), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + await expect(service(fakeGateway(), signer).signChecked(scope(), NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "signing_rejected" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("requires threshold before broadcast and reports the dynamic multi-sign fee", async () => { + const gateway = fakeGateway(); + await expect(service(gateway).broadcastHex(scope(), NETWORK, unsignedHex(), false)) + .rejects.toMatchObject({ code: "not_authorized" }); + + const multiSigned = encodeTransactionHex({ + ...decodeTransactionHex(unsignedHex()), + signature: [SIG_A, SIG_B], + }); + const dry = await service(gateway).broadcastHex(scope(), NETWORK, multiSigned, true); + expect(dry).toMatchObject({ multiSignFeeSun: 1_000_000, mode: "dry-run" }); + expect(gateway.broadcastHex).not.toHaveBeenCalled(); + + await service(gateway).broadcastHex(scope(), NETWORK, multiSigned, false); + expect(gateway.broadcastHex).toHaveBeenCalledWith(multiSigned); + }); + + it("fails closed when the two node approval endpoints disagree", async () => { + const gateway = fakeGateway({ getApprovedList: vi.fn(async () => [B]) }); + await expect(service(gateway).approvals(NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "provider_error" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/multisig-service.ts b/ts/src/application/use-cases/tron/multisig-service.ts new file mode 100644 index 000000000..727f2841a --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-service.ts @@ -0,0 +1,127 @@ +import type { + NetworkDescriptor, + CheckedTxSignView, + TronTransactionArtifact, + TxApprovalView, +} from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { operationForContractType } from "../../../domain/permission/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import { stageTronBroadcast } from "../../services/tron-confirmation.js"; +import type { TronSigService } from "./sig-service.js"; +import { + assertTronSignerAuthorized, + authorizationState, +} from "./multisig-authorization.js"; +import { + assertNotExpired, + expirationOf, + transactionContract, +} from "./transaction-artifact.js"; + +export class TronMultisigService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly signing: TronSigService, + private readonly now: () => number = () => Date.now(), + ) {} + + async approvals(network: NetworkDescriptor, hex: string): Promise { + const gateway = this.gateways.get(network, "tron"); + return this.#approvalFor(gateway, gateway.decodeTransactionHex(hex)); + } + + async signChecked( + scope: TransactionScope, + network: NetworkDescriptor, + hex: string, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(hex); + const expectedSigner = scope.resolveAddress("tron"); + await assertTronSignerAuthorized(gateway, transaction, expectedSigner, this.now()); + + const signed = await this.signing.sign(scope, network, hex, { expectedSigner }); + const approval = await this.#approvalFor(gateway, gateway.decodeTransactionHex(signed.hex)); + const approvedSigner = approval.approved.find((approved) => approved.address === signed.signer); + if (!approvedSigner) { + throw new ChainError("signing_rejected", "node did not recognize the newly appended signature"); + } + return { + ...signed, + checked: true, + signerWeight: approvedSigner.weight, + approval, + }; + } + + async broadcastHex(scope: TransactionScope, network: NetworkDescriptor, hex: string, dryRun: boolean) { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(hex); + const approval = await this.#assertBroadcastable(gateway, transaction); + const multiSignFeeSun = approval.signatures > 1 ? await gateway.getMultiSignFee() : 0; + if (dryRun) { + return { kind: "broadcast" as const, mode: "dry-run" as const, transaction: approval, multiSignFeeSun }; + } + const result = await gateway.broadcastHex(hex); + return { + kind: "broadcast" as const, + ...(await stageTronBroadcast(gateway, scope, result)), + transaction: approval, + multiSignFeeSun, + }; + } + + async broadcastJson( + scope: TransactionScope, + network: NetworkDescriptor, + transaction: TronTransactionArtifact, + dryRun: boolean, + ) { + const gateway = this.gateways.get(network, "tron"); + return this.broadcastHex(scope, network, gateway.encodeTransactionHex(transaction), dryRun); + } + + async #assertBroadcastable(gateway: TronGateway, transaction: TronTransactionArtifact) { + assertNotExpired(transaction, this.now()); + const approval = await this.#approvalFor(gateway, transaction); + if (!approval.thresholdReached) { + throw new ChainError( + "not_authorized", + `signature threshold is not reached; missing ${approval.missingWeight} weight`, + ); + } + return approval; + } + + async #approvalFor(gateway: TronGateway, transaction: TronTransactionArtifact): Promise { + const { weight, approved, permission } = await authorizationState(gateway, transaction); + const expiration = expirationOf(transaction); + const contractType = transactionContract(transaction).type; + const decoded = gateway.decodeTransaction(transaction); + const keyWeights = new Map(permission.keys.map((key) => [key.address, key.weight])); + return { + txId: transaction.txID, + contractType, + operation: operationForContractType(contractType)?.label, + from: decoded.from, + to: decoded.to, + rawAmount: decoded.rawAmount, + tokenContract: decoded.tokenContract, + permission: { + id: permission.id, + name: permission.name, + threshold: permission.threshold, + }, + currentWeight: weight.currentWeight, + missingWeight: Math.max(0, permission.threshold - weight.currentWeight), + thresholdReached: weight.currentWeight >= permission.threshold, + approved: approved.map((address) => ({ address, weight: keyWeights.get(address)! })), + expiration, + expired: expiration <= this.now(), + signatures: transaction.signature?.length ?? 0, + }; + } +} diff --git a/ts/src/application/use-cases/tron/permission-service.test.ts b/ts/src/application/use-cases/tron/permission-service.test.ts new file mode 100644 index 000000000..4acb2a41a --- /dev/null +++ b/ts/src/application/use-cases/tron/permission-service.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AccountPermissionsView } from "../../../domain/types/index.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { AccountStore } from "../../ports/account-store.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import { TronPermissionService } from "./permission-service.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const NETWORK = { id: "tron:nile", family: "tron" } as never; + +function permissions(): AccountPermissionsView { + return { + address: A, + owner: { + id: 0, + name: "owner", + threshold: 2, + keys: [{ address: A, weight: 1, local: null }, { address: B, weight: 1, local: null }], + }, + witness: null, + actives: [{ + id: 2, + name: "finance", + threshold: 1, + keys: [{ address: A, weight: 1, local: null }], + operations: ["TransferContract"], + operationLabels: ["Transfer TRX"], + operationsHex: "02" + "00".repeat(31), + unknownOperationIds: [], + }], + }; +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + resolveAddress: () => A, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function accounts(): AccountStore { + return { + list: () => [{ + accountId: "local" as never, + label: "main", + type: "privateKey", + index: null, + active: true, + addresses: { tron: A }, + }], + } as unknown as AccountStore; +} + +function setup(balance = "200000000") { + const gateway = { + getAccountPermissions: vi.fn(async () => permissions()), + getUpdateAccountPermissionFee: vi.fn(async () => 100_000_000), + getNativeBalance: vi.fn(async () => balance), + buildAccountPermissionUpdate: vi.fn(async () => ({ txID: "tx" })), + prepareTransaction: vi.fn((tx) => tx), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway; + let captured: TxPipelineParams | undefined; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + captured = params; + const tx = await params.build(A); + return { stage: "plan" as const, tx, fee: await params.estimate(tx) }; + }), + } as unknown as TxPipeline; + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + return { + gateway, + pipeline, + service: new TronPermissionService(provider, accounts(), pipeline), + captured: () => captured, + }; +} + +describe("TRON permission service", () => { + it("annotates locally controlled keys on show", async () => { + const { service } = setup(); + const view = await service.show(NETWORK, A); + expect(view.owner.keys.map((key) => key.local)).toEqual(["main", null]); + }); + + it("dry-runs with dynamic fee, emits structured warnings, and never gates a signer", async () => { + const { service, pipeline, captured } = setup(); + const ctx = scope(); + const result = await service.update(ctx, NETWORK, { dryRun: true }, permissions()); + expect(result).toMatchObject({ kind: "permission-update", mode: "dry-run" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ code: "owner_lockout_partial" })); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + expect(captured()?.permissionId).toBe(0); + expect((result as unknown as { fee: unknown }).fee).toMatchObject({ + feeSun: 100_000_000, + balanceSun: "200000000", + }); + }); + + // The validator preserving unnamed bits is worthless if the builder re-derives the bitmap from + // the named operations — that is where the original round-trip loss happened. + it("hands the builder the full bitmap, unnamed bits included, and warns about them", async () => { + const { service, gateway } = setup(); + const requested = permissions(); + requested.actives[0]!.operationsHex = "82" + "00".repeat(31); + requested.actives[0]!.unknownOperationIds = [7]; + const ctx = scope(); + await service.update(ctx, NETWORK, { dryRun: true }, requested); + expect(gateway.buildAccountPermissionUpdate).toHaveBeenCalledWith( + A, + expect.objectContaining({ + actives: [expect.objectContaining({ + operationsHex: "82" + "00".repeat(31), + unknownOperationIds: [7], + })], + }), + ); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ code: "active_unknown_operations" })); + }); + + it("allows an offline build even when balance is currently low", async () => { + const { service, pipeline } = setup("0"); + await expect(service.update(scope(), NETWORK, { buildOnly: true }, permissions())).resolves.toBeDefined(); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + }); + + it("rejects insufficient balance before constructing a broadcast transaction", async () => { + const { service, gateway, pipeline } = setup("99999999"); + await expect(service.update(scope(), NETWORK, {}, permissions())).rejects.toMatchObject({ code: "insufficient_balance" }); + expect(gateway.buildAccountPermissionUpdate).not.toHaveBeenCalled(); + expect(pipeline.run).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/permission-service.ts b/ts/src/application/use-cases/tron/permission-service.ts new file mode 100644 index 000000000..7ceeff08b --- /dev/null +++ b/ts/src/application/use-cases/tron/permission-service.ts @@ -0,0 +1,113 @@ +import type { AccountPermissionsView, NetworkDescriptor } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { + annotateLocalPermissionKeys, + buildLocalPermissionInventory, + permissionSafetyWarnings, + validatePermissionStructure, +} from "../../../domain/permission/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { AccountStore } from "../../ports/account-store.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { assertTronSignerAuthorized } from "./multisig-authorization.js"; + +export class TronPermissionService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly accounts: Pick, + private readonly pipeline: TxPipeline, + ) {} + + async show(network: NetworkDescriptor, address: string): Promise { + const permissions = await this.gateways.get(network, "tron").getAccountPermissions(address); + return annotateLocalPermissionKeys(permissions, buildLocalPermissionInventory(this.accounts.list())); + } + + async update( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionModeInput, + requested: unknown, + ) { + const gateway = this.gateways.get(network, "tron"); + const address = scope.resolveAddress("tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); + const inventory = buildLocalPermissionInventory(this.accounts.list()); + const permissions = annotateLocalPermissionKeys( + validatePermissionStructure(requested, address), + inventory, + ); + for (const warning of permissionSafetyWarnings(permissions, inventory)) scope.warn(warning); + + const mode = transactionMode(input); + const [feeSun, balanceSun] = await Promise.all([ + gateway.getUpdateAccountPermissionFee(), + gateway.getNativeBalance(address), + ]); + if (mode.mode === "broadcast" && BigInt(balanceSun) < BigInt(feeSun)) { + throw new ChainError( + "insufficient_balance", + `account balance ${balanceSun} SUN is below the ${feeSun} SUN permission update fee`, + ); + } + + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + prepare: (tx, options) => gateway.prepareTransaction(tx, options), + artifact: (tx) => gateway.encodeTransactionHex(tx), + preflight: (tx, signer) => assertTronSignerAuthorized(gateway, tx, signer), + confirm: tronConfirmation(gateway, scope), + build: () => gateway.buildAccountPermissionUpdate(address, permissions), + estimate: async () => ({ feeModel: "tron-resource", feeSun, balanceSun }), + }); + + let resultPermissions: AccountPermissionsView | undefined; + if (outcome.stage === "confirmed") { + resultPermissions = await this.show(network, address); + if (canonicalPermissions(resultPermissions) !== canonicalPermissions(permissions)) { + scope.warn({ + code: "permission_postcheck_mismatch", + message: "confirmed transaction permissions differ from the requested canonical structure", + }); + } + } else if (outcome.stage === "plan" || outcome.stage === "built" || outcome.stage === "signed") { + resultPermissions = permissions; + } + return { + kind: "permission-update" as const, + ...outcomeData(outcome), + ...(resultPermissions ? { permissions: resultPermissions } : {}), + }; + } +} + +function canonicalPermissions(value: AccountPermissionsView): string { + const key = ({ address, weight }: { address: string; weight: number }) => ({ address, weight }); + const group = (permission: AccountPermissionsView["owner"]) => ({ + id: permission.id, + name: permission.name, + threshold: permission.threshold, + keys: permission.keys.map(key), + }); + return JSON.stringify({ + address: value.address, + owner: group(value.owner), + witness: value.witness ? group(value.witness) : null, + actives: value.actives.map((permission) => ({ + ...group(permission), + operationsHex: permission.operationsHex, + })), + }); +} diff --git a/ts/src/application/use-cases/tron/reward-service.ts b/ts/src/application/use-cases/tron/reward-service.ts index 58228d591..1d49dbe4e 100644 --- a/ts/src/application/use-cases/tron/reward-service.ts +++ b/ts/src/application/use-cases/tron/reward-service.ts @@ -4,8 +4,14 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronAccount } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; const WITHDRAW_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -31,7 +37,7 @@ export class TronRewardService { } async withdraw(scope: TransactionScope, network: NetworkDescriptor, input: TransactionModeInput) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const address = scope.resolveAddress("tron"); const [rewardSun, account] = await Promise.all([ @@ -51,6 +57,7 @@ export class TronRewardService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (owner) => gateway.buildWithdrawBalance(owner), estimate: async () => ({ feeModel: "tron-resource", note: "reward withdrawal uses bandwidth only" }), diff --git a/ts/src/application/use-cases/tron/sig-service.test.ts b/ts/src/application/use-cases/tron/sig-service.test.ts new file mode 100644 index 000000000..379ea6212 --- /dev/null +++ b/ts/src/application/use-cases/tron/sig-service.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OfflineTxSignView, Signer } from "../../../domain/types/index.js"; +import { + decodeTransactionHex, + encodeTransactionHex, +} from "../../../adapters/outbound/chain/tron/transaction-codec.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import { TronSigService } from "./sig-service.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OWNER_HEX = "417445076632894b7b844887d2bcd2e8c30bb6c6f2"; +const TO_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; +const SIGNATURE = "ab".repeat(65); +const NOW = 1_900_000_000_000; +const NETWORK = { id: "tron:nile", family: "tron" } as never; + +function unsignedHex(expiration = NOW + 60_000): string { + return encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, to_address: TO_HEX, amount: 1 }, + type_url: "type.googleapis.com/protocol.TransferContract", + }, + type: "TransferContract", + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: NOW, + expiration, + }, + }); +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + resolveAddress: () => A, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function setup(signerOverride?: Signer) { + const gateway = { + decodeTransactionHex, + encodeTransactionHex, + decodeTransaction: () => ({ kind: "trx", from: A, to: B, rawAmount: "1" }), + } as unknown as TronGateway; + const signer = signerOverride ?? { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => ({ + ...transaction, + signature: [...(transaction.signature ?? []), SIGNATURE], + })), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + } as Signer; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + const gateways = { get: vi.fn(() => gateway) } as unknown as ChainGatewayProvider; + return { service: new TronSigService(gateways, signers, () => NOW), signer }; +} + +describe("TRON artifact signing", () => { + it("appends a signature without requiring online approval endpoints", async () => { + const before = decodeTransactionHex(unsignedHex()); + const result: OfflineTxSignView = await setup().service.sign(scope(), NETWORK, unsignedHex()); + const after = decodeTransactionHex(result.hex); + + expect(after.txID).toBe(before.txID); + expect(after.raw_data_hex).toBe(before.raw_data_hex); + expect(after.signature).toEqual([SIGNATURE]); + expect(result).toMatchObject({ + kind: "tx-sign", + checked: false, + signer: A, + transaction: { + permissionId: 2, + signatures: 1, + contractType: "TransferContract", + }, + }); + }); + + it("rejects an expired artifact before invoking the signer", async () => { + const { service, signer } = setup(); + await expect(service.sign(scope(), NETWORK, unsignedHex(NOW))) + .rejects.toMatchObject({ code: "tx_expired" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("rejects a signer that changes raw_data instead of only appending a signature", async () => { + const signer = { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => ({ + ...transaction, + txID: "ff".repeat(32), + signature: [SIGNATURE], + })), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + } as Signer; + await expect(setup(signer).service.sign(scope(), NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "invalid_transaction" }); + }); + + it("rejects an unexpected signer before asking it to sign", async () => { + const signer = { + kind: "software", + address: B, + sign: vi.fn(async (transaction) => transaction), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + } as Signer; + await expect(setup(signer).service.sign( + scope(), + NETWORK, + unsignedHex(), + { expectedSigner: A }, + )).rejects.toMatchObject({ code: "signing_rejected" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/sig-service.ts b/ts/src/application/use-cases/tron/sig-service.ts new file mode 100644 index 000000000..e26899f94 --- /dev/null +++ b/ts/src/application/use-cases/tron/sig-service.ts @@ -0,0 +1,106 @@ +import type { + NetworkDescriptor, + Signer, + TronTransactionArtifact, + OfflineTxSignView, + TxSignTransactionView, +} from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { operationForContractType } from "../../../domain/permission/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import { obtainSignature } from "../../services/signing/obtain-signature.js"; +import { + assertNotExpired, + expirationOf, + transactionContract, +} from "./transaction-artifact.js"; + +export interface TronSignOptions { + /** Bind an authorization decision to the exact signer before any signature interaction. */ + expectedSigner?: string; +} + +/** Offline-capable TRON artifact signing. It never queries permission or approval endpoints. */ +export class TronSigService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly signers: SignerResolver, + private readonly now: () => number = () => Date.now(), + ) {} + + async sign( + scope: TransactionScope, + network: NetworkDescriptor, + hex: string, + options: TronSignOptions = {}, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(hex); + assertNotExpired(transaction, this.now()); + const originalTxId = transaction.txID; + const originalRawDataHex = transaction.raw_data_hex; + const previousSignatures = [...(transaction.signature ?? [])]; + + this.signers.assertCanSign(scope.activeAccount, "tron"); + const signer = this.signers.resolve(scope.activeAccount, "tron"); + if (options.expectedSigner !== undefined && signer.address !== options.expectedSigner) { + throw new ChainError( + "signing_rejected", + "resolved signer does not match the signer that passed authorization", + ); + } + const signed = await this.#sign(signer, transaction, scope); + const signedHex = gateway.encodeTransactionHex(signed); + const decoded = gateway.decodeTransactionHex(signedHex); + if (decoded.txID !== originalTxId || decoded.raw_data_hex !== originalRawDataHex) { + throw new ChainError("invalid_transaction", "signer changed the transaction raw_data or txID"); + } + const current = decoded.signature ?? []; + if ( + current.length !== previousSignatures.length + 1 + || previousSignatures.some((signature, index) => current[index] !== signature) + ) { + throw new ChainError( + "signing_rejected", + "signer did not append exactly one signature while preserving prior approvals", + ); + } + assertNotExpired(decoded, this.now()); + return { + kind: "tx-sign", + signer: signer.address, + hex: signedHex, + checked: false, + transaction: this.#transactionView(gateway, decoded), + }; + } + + #transactionView( + gateway: TronGateway, + transaction: TronTransactionArtifact, + ): TxSignTransactionView { + const contract = transactionContract(transaction); + const decoded = gateway.decodeTransaction(transaction); + const expiration = expirationOf(transaction); + return { + txId: transaction.txID, + contractType: contract.type, + operation: operationForContractType(contract.type)?.label, + from: decoded.from, + to: decoded.to, + rawAmount: decoded.rawAmount, + tokenContract: decoded.tokenContract, + permissionId: contract.Permission_id ?? 0, + expiration, + expired: expiration <= this.now(), + signatures: transaction.signature?.length ?? 0, + }; + } + + #sign(signer: Signer, transaction: TronTransactionArtifact, scope: TransactionScope) { + return obtainSignature(signer, scope, (options) => signer.sign(transaction, options)); + } +} diff --git a/ts/src/application/use-cases/tron/stake-service.ts b/ts/src/application/use-cases/tron/stake-service.ts index 4203e66f7..aa6febe11 100644 --- a/ts/src/application/use-cases/tron/stake-service.ts +++ b/ts/src/application/use-cases/tron/stake-service.ts @@ -5,8 +5,14 @@ import type { AccountScope, TransactionScope } from "../../contracts/execution-s import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; export interface StakeAmountInput extends TransactionModeInput { amountSun: string; @@ -203,7 +209,7 @@ export class TronStakeService { build: (gateway: TronGateway, owner: string) => Promise, opts?: { requireSoftware?: boolean }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron", opts); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron", opts); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, @@ -211,6 +217,8 @@ export class TronStakeService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), + signerOptions: opts, confirm: tronConfirmation(gateway, scope), build: (owner) => build(gateway, owner), estimate: async () => ({ feeModel: "tron-resource", note: "staking ops cost bandwidth" }), diff --git a/ts/src/application/use-cases/tron/transaction-artifact.ts b/ts/src/application/use-cases/tron/transaction-artifact.ts new file mode 100644 index 000000000..86ee91f69 --- /dev/null +++ b/ts/src/application/use-cases/tron/transaction-artifact.ts @@ -0,0 +1,28 @@ +import { ChainError } from "../../../domain/errors/index.js"; +import type { TronTransactionArtifact } from "../../../domain/types/index.js"; + +export function transactionContract(transaction: TronTransactionArtifact) { + const contracts = transaction.raw_data?.contract; + if (!Array.isArray(contracts) || contracts.length !== 1) { + throw new ChainError("invalid_transaction", "transaction must contain exactly one contract"); + } + return contracts[0]!; +} + +export function expirationOf(transaction: TronTransactionArtifact): number { + const expiration = transaction.raw_data.expiration; + if (!Number.isSafeInteger(expiration) || expiration! <= 0) { + throw new ChainError("invalid_transaction", "transaction expiration is missing or imprecise"); + } + return expiration!; +} + +export function assertNotExpired( + transaction: TronTransactionArtifact, + now = Date.now(), +): void { + const expiration = expirationOf(transaction); + if (expiration <= now) { + throw new ChainError("tx_expired", `transaction expired at ${new Date(expiration).toISOString()}`); + } +} diff --git a/ts/src/application/use-cases/tron/transaction-service.send.test.ts b/ts/src/application/use-cases/tron/transaction-service.send.test.ts new file mode 100644 index 000000000..81e3c92da --- /dev/null +++ b/ts/src/application/use-cases/tron/transaction-service.send.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronTransactionService } from "./transaction-service.js"; + +const OWNER = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const RECEIVER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; +const NETWORK = { + id: "tron:nile", + family: "tron", + chainId: "nile", + aliases: ["nile"], + capabilities: [], +} satisfies NetworkDescriptor; + +function scope(): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +describe("TronTransactionService recipient resolution", () => { + it("builds and estimates against the resolved contact address", async () => { + const gateway = { + buildNativeTransfer: vi.fn(async () => ({ txID: "tx" })), + prepareTransaction: vi.fn((transaction) => transaction), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + const transaction = await params.build(OWNER); + return { + stage: "plan" as const, + tx: transaction, + fee: await params.estimate(transaction), + }; + }), + } as unknown as TxPipeline; + const recipients = { + resolve: vi.fn(() => ({ + address: RECEIVER, + contactName: "Alice", + })), + }; + const service = new TronTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + {} as never, + pipeline, + recipients as never, + ); + + const result = await service.send(scope(), NETWORK, { + to: "alice", + rawAmount: "1000000", + feeLimit: "100000000", + dryRun: true, + }); + + expect(recipients.resolve).toHaveBeenCalledWith("tron", "alice"); + expect(gateway.buildNativeTransfer).toHaveBeenCalledWith( + OWNER, + RECEIVER, + "1000000", + ); + expect(result).toMatchObject({ + kind: "send", + mode: "dry-run", + to: RECEIVER, + toContact: "Alice", + }); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/transaction-service.status.test.ts b/ts/src/application/use-cases/tron/transaction-service.status.test.ts index 5c6968160..a755959fd 100644 --- a/ts/src/application/use-cases/tron/transaction-service.status.test.ts +++ b/ts/src/application/use-cases/tron/transaction-service.status.test.ts @@ -19,7 +19,12 @@ function service(opts: { tx?: TronTx | Error; info?: TronTxInfo }) { }, } as unknown as TronGateway; const gateways = { get: () => gateway } as unknown as ChainGatewayProvider; - return new TronTransactionService(gateways, {} as never, {} as never); + return new TronTransactionService( + gateways, + {} as never, + {} as never, + {} as never, + ); } describe("TronTransactionService.status — four-state", () => { diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index 615050d62..4b14f0f71 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -6,8 +6,15 @@ import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js import type { DecodedTronTransaction, TronGateway, TronTxInfo } from "../../ports/chain/tron-gateway.js"; import type { TokenRepository } from "../../ports/token-repository.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { stageTronBroadcast, tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; +import type { RecipientResolver } from "../../services/recipient-resolver.js"; export interface TronSendInput extends TransactionModeInput { to: string; @@ -24,11 +31,13 @@ export class TronTransactionService { private readonly gateways: ChainGatewayProvider, private readonly tokens: TokenRepository, private readonly pipeline: TxPipeline, + private readonly recipients: RecipientResolver, ) {} async send(scope: TransactionScope, network: NetworkDescriptor, input: TronSendInput) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); + const recipient = this.recipients.resolve("tron", input.to); const resolved = await this.resolveTransfer( gateway, network.id, @@ -41,15 +50,16 @@ export class TronTransactionService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (from) => resolved.contract - ? gateway.buildTrc20Transfer(from, input.to, resolved.contract, resolved.rawAmount, input.feeLimit) + ? gateway.buildTrc20Transfer(from, recipient.address, resolved.contract, resolved.rawAmount, input.feeLimit) : resolved.assetId - ? gateway.buildTrc10Transfer(from, input.to, resolved.assetId, resolved.rawAmount) - : gateway.buildNativeTransfer(from, input.to, resolved.rawAmount), + ? gateway.buildTrc10Transfer(from, recipient.address, resolved.assetId, resolved.rawAmount) + : gateway.buildNativeTransfer(from, recipient.address, resolved.rawAmount), estimate: () => resolved.contract ? gateway.estimateResources(scope.resolveAddress("tron"), resolved.contract, "transfer(address,uint256)", [ - { type: "address", value: input.to }, + { type: "address", value: recipient.address }, { type: "uint256", value: resolved.rawAmount }, ]) : Promise.resolve(resolved.assetId @@ -64,7 +74,10 @@ export class TronTransactionService { decimals: resolved.decimals, contract: resolved.contract, assetId: resolved.assetId, - to: input.to, + to: recipient.address, + ...(recipient.contactName + ? { toContact: recipient.contactName } + : {}), }; } diff --git a/ts/src/application/use-cases/tron/vote-service.ts b/ts/src/application/use-cases/tron/vote-service.ts index eaf5af069..4eea133de 100644 --- a/ts/src/application/use-cases/tron/vote-service.ts +++ b/ts/src/application/use-cases/tron/vote-service.ts @@ -12,9 +12,15 @@ import type { TronWitness, } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { TronStakeService } from "./stake-service.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; const MAX_VOTE_ITEMS = 30; const MAX_REWARDED_RANK = 127; @@ -70,7 +76,7 @@ export class TronVoteService { ) {} async cast(scope: TransactionScope, network: NetworkDescriptor, input: VoteCastInput) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const votes = parseVoteInputs(input.for); const totalVotes = votes.reduce((sum, vote) => sum + BigInt(vote.count), 0n); @@ -91,6 +97,7 @@ export class TronVoteService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (ownerAddress) => gateway.buildVoteWitness(ownerAddress, votes), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "voting uses bandwidth only" }), diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index a33d783c8..ddf9208ee 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -52,8 +52,8 @@ export class WalletService { return { previous: result.previous, ...this.wallets.describe(result.accountId) }; } - current() { - const account = this.wallets.activeAccount(); + current(requestedAccount?: string) { + const account = requestedAccount ?? this.wallets.activeAccount(); if (!account) throw new WalletError("missing_wallet_address", "no active account; import one first"); return this.wallets.describe(account); } diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index bc2d45468..939d0c7e9 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -29,6 +29,18 @@ import { ConfigService } from "../application/use-cases/config-service.js"; import { WalletService } from "../application/use-cases/wallet-service.js"; import { FAMILY_REGISTRY, familyMap } from "./family-registry.js"; import { registerTronChainCommands } from "./families/tron.js"; +import { TronLinkClient } from "../adapters/outbound/tronlink/client.js"; +import { GasFreeClient } from "../adapters/outbound/gasfree/client.js"; +import { ContactBook } from "../adapters/outbound/contactbook/index.js"; +import { ContactService } from "../application/use-cases/contact-service.js"; +import { RecipientResolver } from "../application/services/recipient-resolver.js"; +import { registerContactCommands } from "../adapters/inbound/cli/commands/contact.js"; +import { EncodingService } from "../application/use-cases/encoding-service.js"; +import { AddressService } from "../application/use-cases/address-service.js"; +import { SecureKeypairWriter } from "../adapters/outbound/persistence/keypair-writer.js"; +import { registerEncodingCommands } from "../adapters/inbound/cli/commands/encoding.js"; +import { registerAddressCommands } from "../adapters/inbound/cli/commands/address.js"; +import { TerminalQrEncoder } from "../adapters/outbound/qr/index.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -46,6 +58,7 @@ export function composeCliRuntime(options: BootstrapOptions) { const formatter = createOutputFormatter(output, streams, options.startedAt); const root = ConfigLoader.resolveRoot(); + streams.diagnostic("debug", `wallet root: ${root}`); const store = new AtomicFileStore(); const configService = new ConfigService( new YamlConfigDocument(ConfigLoader.configPath(), store), @@ -61,6 +74,8 @@ export function composeCliRuntime(options: BootstrapOptions) { new SecureBackupWriter(root), ); const tokenBook = new TokenBook(root, store); + const contactBook = new ContactBook(root, store); + const recipientResolver = new RecipientResolver(contactBook); const priceProvider = createPriceProvider(config.price, timeoutMs); const gatewayProvider = new ChainGatewayRegistry( familyMap((plugin) => plugin.createGateway), @@ -75,24 +90,41 @@ export function composeCliRuntime(options: BootstrapOptions) { const txPipeline = new TxPipeline(signerResolver); const registry = new CommandRegistry(); - registerWalletCommands(registry, { walletService, ledger }); + registerWalletCommands(registry, { + walletService, + ledger, + qr: new TerminalQrEncoder(), + }); registerConfigCommands(registry, configService); registerNetworkCommands(registry); + registerContactCommands(registry, new ContactService(contactBook)); + registerEncodingCommands(registry, new EncodingService()); + registerAddressCommands( + registry, + new AddressService(root, new SecureKeypairWriter()), + ); registerTronChainCommands(registry, { gateways: gatewayProvider, tokens: tokenBook, prices: priceProvider, signers: signerResolver, transactions: txPipeline, + accounts: keystore, timeoutMs, + tronlink: new TronLinkClient(config, timeoutMs), + gasfree: new GasFreeClient(config, timeoutMs), + recipients: recipientResolver, }); const capabilitiesByFamily = registry.capabilityKeysByFamily(); for (const network of Object.values(config.networks)) { - const commandCapabilities = (capabilitiesByFamily.get(network.family) ?? []).map((key) => ({ - key, - summary: CAP_SUMMARIES[key] ?? key, - })); + const commandCapabilities = (capabilitiesByFamily.get(network.family) ?? []) + .filter((key) => key !== "tx.multisig.tronlink" || Boolean(network.tronlinkHttpEndpoint)) + .filter((key) => !key.startsWith("gasfree.") || Boolean(network.gasfree)) + .map((key) => ({ + key, + summary: CAP_SUMMARIES[key] ?? key, + })); const traits = network.capabilities.map((key) => ({ key, summary: TRAIT_SUMMARIES[key] ?? key, diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 9e00fd3fc..ebdc16a0d 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -4,6 +4,8 @@ import { TronRpcClient } from "../../adapters/outbound/chain/tron/tron.js"; import { TronGridHistoryReader } from "../../adapters/outbound/chain/tron/history-reader.js"; import { blockSpec, blockTronBinding } from "../../adapters/inbound/cli/commands/block.js"; import { + accountActivateSpec, + accountActivateTronBinding, accountBalanceSpec, accountBalanceTronBinding, accountHistorySpec, @@ -12,6 +14,8 @@ import { accountInfoTronBinding, accountPortfolioSpec, accountPortfolioTronBinding, + accountSetSpec, + accountSetTronBinding, } from "../../adapters/inbound/cli/commands/account.js"; import { tokenAddSpec, @@ -27,15 +31,25 @@ import { } from "../../adapters/inbound/cli/commands/token.js"; import { messageSignSpec, messageSignBinding } from "../../adapters/inbound/cli/commands/shared.js"; import { typedDataSignSpec, typedDataSignBinding } from "../../adapters/inbound/cli/commands/typed-data.js"; +import { + permissionShowSpec, + permissionShowTronBinding, + permissionUpdateSpec, + permissionUpdateTronBinding, +} from "../../adapters/inbound/cli/commands/permission.js"; import { txBroadcastSpec, txBroadcastTronBinding, + txApprovalsSpec, + txApprovalsTronBinding, txInfoSpec, txInfoTronBinding, txSendSpec, txSendTronBinding, txSignSpec, txSignTronBinding, + txTronLinkMultisigBinding, + txTronLinkMultisigSpec, txStatusSpec, txStatusTronBinding, } from "../../adapters/inbound/cli/commands/tx.js"; @@ -77,12 +91,30 @@ import { TronChainService } from "../../application/use-cases/tron/chain-service import { TronBlockService } from "../../application/use-cases/tron/block-service.js"; import { MessageService } from "../../application/use-cases/message-service.js"; import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; +import { TronPermissionService } from "../../application/use-cases/tron/permission-service.js"; +import { TronSigService } from "../../application/use-cases/tron/sig-service.js"; +import { TronMultisigService } from "../../application/use-cases/tron/multisig-service.js"; +import { TronMultisigCollaborationService } from "../../application/use-cases/tron/multisig-collaboration-service.js"; import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js"; import type { TokenRepository } from "../../application/ports/token-repository.js"; import type { PriceProvider } from "../../application/ports/price-provider.js"; import type { SignerResolver } from "../../application/services/signer/index.js"; import type { TxPipeline } from "../../application/services/pipeline/index.js"; +import type { AccountStore } from "../../application/ports/account-store.js"; +import { TransactionArtifactWriter } from "../../adapters/outbound/persistence/transaction-artifact-writer.js"; import type { FamilyPlugin } from "./types.js"; +import type { TronLinkCollaborationPort } from "../../application/ports/tronlink-collaboration.js"; +import type { GasFreeProvider } from "../../application/ports/gasfree-provider.js"; +import type { RecipientResolver } from "../../application/services/recipient-resolver.js"; +import { GasFreeService } from "../../application/use-cases/tron/gasfree-service.js"; +import { + gasFreeInfoSpec, + gasFreeInfoTronBinding, + gasFreeTraceSpec, + gasFreeTraceTronBinding, + gasFreeTransferSpec, + gasFreeTransferTronBinding, +} from "../../adapters/inbound/cli/commands/gasfree.js"; export const tronFamily: FamilyPlugin<"tron"> = { meta: FAMILIES.tron, @@ -96,7 +128,11 @@ export interface TronChainCommandDependencies { prices: PriceProvider; signers: SignerResolver; transactions: TxPipeline; + accounts: AccountStore; timeoutMs: number; + tronlink: TronLinkCollaborationPort; + gasfree: GasFreeProvider; + recipients: RecipientResolver; } export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainCommandDependencies): void { @@ -105,11 +141,31 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC new TronGridHistoryReader(deps.timeoutMs), deps.tokens, deps.prices, + deps.transactions, ); const token = new TronTokenService(deps.gateways, deps.tokens); const message = new MessageService(deps.signers); const typedData = new TypedDataService(deps.signers); - const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); + const transaction = new TronTransactionService( + deps.gateways, + deps.tokens, + deps.transactions, + deps.recipients, + ); + const signing = new TronSigService(deps.gateways, deps.signers); + const multisig = new TronMultisigService(deps.gateways, signing); + const multisigCollaboration = new TronMultisigCollaborationService( + deps.tronlink, + deps.gateways, + multisig, + ); + const gasfree = new GasFreeService( + deps.gasfree, + deps.gateways, + deps.signers, + deps.recipients, + ); + const permission = new TronPermissionService(deps.gateways, deps.accounts, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); const vote = new TronVoteService(deps.gateways, deps.transactions, stake); const reward = new TronRewardService(deps.gateways, deps.transactions); @@ -117,10 +173,12 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const contract = new TronContractService(deps.gateways, deps.transactions); reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); + reg.addChain(accountActivateSpec, "tron", accountActivateTronBinding(account)); reg.addChain(accountBalanceSpec, "tron", accountBalanceTronBinding(account)); reg.addChain(accountInfoSpec, "tron", accountInfoTronBinding(account)); reg.addChain(accountHistorySpec, "tron", accountHistoryTronBinding(account)); reg.addChain(accountPortfolioSpec, "tron", accountPortfolioTronBinding(account)); + reg.addChain(accountSetSpec, "tron", accountSetTronBinding(account)); reg.addChain(tokenBalanceSpec, "tron", tokenBalanceTronBinding(token)); reg.addChain(tokenInfoSpec, "tron", tokenInfoTronBinding(token)); reg.addChain(tokenAddSpec, "tron", tokenAddTronBinding(token)); @@ -129,10 +187,26 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC reg.addChain(messageSignSpec, "tron", messageSignBinding(message)); reg.addChain(typedDataSignSpec, "tron", typedDataSignBinding(typedData)); reg.addChain(txSendSpec, "tron", txSendTronBinding(transaction)); - reg.addChain(txSignSpec, "tron", txSignTronBinding(transaction)); - reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(transaction)); + reg.addChain(txSignSpec, "tron", txSignTronBinding( + transaction, + signing, + multisig, + new TransactionArtifactWriter(), + )); + reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); + reg.addChain( + txTronLinkMultisigSpec, + "tron", + txTronLinkMultisigBinding(multisigCollaboration), + ); + reg.addChain(gasFreeInfoSpec, "tron", gasFreeInfoTronBinding(gasfree)); + reg.addChain(gasFreeTransferSpec, "tron", gasFreeTransferTronBinding(gasfree)); + reg.addChain(gasFreeTraceSpec, "tron", gasFreeTraceTronBinding(gasfree)); + reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(multisig)); reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); + reg.addChain(permissionShowSpec, "tron", permissionShowTronBinding(permission)); + reg.addChain(permissionUpdateSpec, "tron", permissionUpdateTronBinding(permission)); for (const definition of stakeDefinitions(stake)) { reg.addChain(definition.spec, "tron", definition.binding); } diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 5caf0978f..479971fae 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -6,7 +6,7 @@ import { buildCli } from "../adapters/inbound/cli/shell/index.js" import { hasCommand, parseGlobals } from "./argv.js" import { composeCliRuntime } from "./composition.js" -export const VERSION = "4.10.1" +export const VERSION = "4.11.0" /** Execute one CLI invocation. Dependency construction is delegated to the composition root. */ export async function main(argv: string[]): Promise { diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index fe5cac927..644c08df5 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -5,7 +5,8 @@ import { keccak_256 } from "@noble/hashes/sha3.js" import { sha256 } from "@noble/hashes/sha2.js" import { createBase58check } from "@scure/base" -import { hexToBytes } from "@noble/hashes/utils.js" +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" +import { secp256k1 } from "@noble/curves/secp256k1.js" import type { Bytes, ChainFamily } from "../types/index.js" export interface AddressCodec { @@ -16,25 +17,78 @@ export interface AddressCodec { const b58c = createBase58check(sha256) -/** uncompressed (65B, 0x04…) or compressed pubkey → last-20-bytes keccak hash. */ -function pubKeyHash20(pub: Bytes): Bytes { - const body = pub.length === 65 ? pub.slice(1) : pub // strip 0x04 prefix +/** Valid SEC1 key → canonical uncompressed 65-byte representation. */ +export function uncompressedPublicKey(pub: Bytes): Bytes { + if (pub.length !== 33 && pub.length !== 65) { + throw new Error("public key must be 33 or 65 bytes") + } + // Point parsing validates prefix, coordinates, and secp256k1 curve membership. + return secp256k1.Point.fromBytes(pub).toBytes(false) +} + +/** compressed/uncompressed public key → last-20-bytes keccak(x || y). */ +export function publicKeyHash20(pub: Bytes): Bytes { + const body = uncompressedPublicKey(pub).slice(1) return keccak_256(body).slice(-20) } +/** Decode and validate a Base58Check TRON address payload. */ +export function tronAddressBytes(address: string): Bytes { + const decoded = b58c.decode(address) + if (decoded.length !== 21 || decoded[0] !== 0x41) { + throw new Error("invalid TRON address") + } + return decoded +} + +export function tronAddressFromBytes(payload: Bytes): string { + if (payload.length !== 21 || payload[0] !== 0x41) { + throw new Error("TRON payload must be 0x41 + 20 bytes") + } + return b58c.encode(payload) +} + +export function tronHexAddress(address: string): string { + return bytesToHex(tronAddressBytes(address)) +} + +export function evmChecksumAddress(hash20: Bytes): string { + if (hash20.length !== 20) { + throw new Error("EVM address payload must be 20 bytes") + } + const lower = bytesToHex(hash20) + const checksum = bytesToHex( + keccak_256(new TextEncoder().encode(lower)), + ) + let result = "0x" + for (let index = 0; index < lower.length; index += 1) { + const char = lower[index]! + result += + /[a-f]/.test(char) + && Number.parseInt(checksum[index]!, 16) >= 8 + ? char.toUpperCase() + : char + } + return result +} + +export function evmAddressFromPublicKey(pub: Bytes): string { + return evmChecksumAddress(publicKeyHash20(pub)) +} + export class TronAddress implements AddressCodec { readonly family: ChainFamily = "tron" fromPublicKey(pub: Bytes): string { const payload = new Uint8Array(21) payload[0] = 0x41 - payload.set(pubKeyHash20(pub), 1) + payload.set(publicKeyHash20(pub), 1) return b58c.encode(payload) } validate(addr: string): boolean { if (!/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) return false try { - const decoded = b58c.decode(addr) - return decoded.length === 21 && decoded[0] === 0x41 + tronAddressBytes(addr) + return true } catch { return false } diff --git a/ts/src/domain/contact/contact.test.ts b/ts/src/domain/contact/contact.test.ts new file mode 100644 index 000000000..dcc11fb08 --- /dev/null +++ b/ts/src/domain/contact/contact.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + contactNameKey, + contactNote, + createContact, +} from "./index.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; + +describe("contact validation", () => { + it("normalizes compatibility-equivalent names into one lookup key", () => { + expect(contactNameKey(" ALICE ")).toBe("alice"); + }); + + it("rejects address-shaped aliases and control characters", () => { + expect(() => contactNameKey(ADDRESS)).toThrow(/must not resemble/); + expect(() => contactNameKey("alice\u202e")).toThrow(/safe characters/); + }); + + it("enforces character limits while preserving valid Unicode", () => { + expect(contactNote("账".repeat(128))).toBe("账".repeat(128)); + expect(() => contactNote("账".repeat(129))).toThrow(/128/); + expect(createContact("tron", "财务", ADDRESS)).toMatchObject({ + name: "财务", + address: ADDRESS, + family: "tron", + }); + }); + + it("rejects an invalid Base58Check address", () => { + expect(() => + createContact( + "tron", + "alice", + "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX", + ) + ).toThrow(/Base58Check/); + }); +}); diff --git a/ts/src/domain/contact/index.ts b/ts/src/domain/contact/index.ts new file mode 100644 index 000000000..535f5f29b --- /dev/null +++ b/ts/src/domain/contact/index.ts @@ -0,0 +1,67 @@ +import type { ChainFamily, ContactEntry } from "../types/index.js"; +import { UsageError } from "../errors/index.js"; +import { TronAddress } from "../address/index.js"; + +const TRON_SHAPED = /^T[1-9A-HJ-NP-Za-km-z]{25,40}$/; +const UNSAFE_TEXT = /[\p{Cc}\p{Cf}]/u; +const ADDRESS = new TronAddress(); + +/** Case-insensitive, compatibility-normalized lookup key. */ +export function contactNameKey(input: string): string { + return contactName(input).normalize("NFKC").toLowerCase(); +} + +export function contactName(input: string): string { + const value = input.trim(); + const length = Array.from(value).length; + if ( + length < 1 + || length > 64 + || UNSAFE_TEXT.test(value) + || TRON_SHAPED.test(value) + ) { + throw new UsageError( + "invalid_value", + "contact name must be 1-64 safe characters and must not resemble a TRON address", + ); + } + return value; +} + +export function contactNote(input?: string): string | null { + if (input === undefined) return null; + const value = input.trim(); + if (Array.from(value).length > 128 || UNSAFE_TEXT.test(value)) { + throw new UsageError( + "invalid_value", + "contact note must contain at most 128 safe characters", + ); + } + return value || null; +} + +export function createContact( + family: ChainFamily, + nameInput: string, + address: string, + noteInput?: string, +): ContactEntry { + if (family !== "tron" || !ADDRESS.validate(address)) { + throw new UsageError( + "invalid_value", + "contact address must be a valid TRON Base58Check address", + ); + } + const name = contactName(nameInput); + return { + family, + name, + nameKey: contactNameKey(name), + address, + note: contactNote(noteInput), + }; +} + +export function resemblesTronAddress(input: string): boolean { + return TRON_SHAPED.test(input.trim()); +} diff --git a/ts/src/domain/encoding/encoding.test.ts b/ts/src/domain/encoding/encoding.test.ts new file mode 100644 index 000000000..c5b0ef394 --- /dev/null +++ b/ts/src/domain/encoding/encoding.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { convertEncoding } from "./index.js"; + +const TRON = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const EVM = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; + +describe("encoding convert", () => { + it("converts TRON/EVM forms without changing the 20-byte payload", () => { + expect(convertEncoding(TRON)).toMatchObject({ + inputType: "tron-base58", + tronHex: "417e5f4552091a69125d5dfcb7b8c2659029395bdf", + evm: EVM, + }); + expect(convertEncoding(EVM)).toMatchObject({ + tron: TRON, + tronHex: "417e5f4552091a69125d5dfcb7b8c2659029395bdf", + }); + }); + + it("derives the same address from compressed and uncompressed public keys", () => { + const secret = Uint8Array.from([...new Uint8Array(31), 1]); + const compressed = bytesToHex(secp256k1.getPublicKey(secret, true)); + const uncompressed = bytesToHex( + secp256k1.getPublicKey(secret, false), + ); + + expect(convertEncoding(compressed)).toMatchObject({ + inputType: "public-key", + tron: TRON, + evm: EVM, + }); + expect(convertEncoding(uncompressed)).toMatchObject({ + inputType: "public-key", + tron: TRON, + evm: EVM, + }); + }); + + it("round-trips generic hex through Base64 and Base58Check", () => { + const converted = convertEncoding("deadbeef0102"); + expect(converted).toMatchObject({ + inputType: "hex", + hex: "deadbeef0102", + base64: "3q2+7wEC", + }); + if (!("base58check" in converted)) throw new Error("wrong family"); + expect(convertEncoding(converted.base64)).toMatchObject({ + hex: "deadbeef0102", + }); + expect(convertEncoding(converted.base58check)).toMatchObject({ + hex: "deadbeef0102", + }); + }); + + it("fails closed for bad checksums, bad curve points, and EIP-55 errors", () => { + expect(() => + convertEncoding("TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX") + ).toThrow(/checksum/); + expect(() => + convertEncoding("0x7E5F4552091A69125d5dfCb7b8C2659029395Bdf") + ).toThrow(/EIP-55/); + expect(() => convertEncoding(`04${"00".repeat(64)}`)).toThrow( + /secp256k1/, + ); + }); + + it("rejects private-key-shaped data in hex and Base64", () => { + expect(() => convertEncoding("11".repeat(32))).toThrow(/private key/); + expect(() => + convertEncoding(Buffer.alloc(32, 0x11).toString("base64")) + ).toThrow(/private key/); + }); +}); diff --git a/ts/src/domain/encoding/index.ts b/ts/src/domain/encoding/index.ts new file mode 100644 index 000000000..b360d3bd3 --- /dev/null +++ b/ts/src/domain/encoding/index.ts @@ -0,0 +1,161 @@ +import { createBase58check } from "@scure/base"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { + evmAddressFromPublicKey, + evmChecksumAddress, + publicKeyHash20, + TronAddress, + tronAddressBytes, + tronAddressFromBytes, +} from "../address/index.js"; +import { UsageError } from "../errors/index.js"; + +const base58check = createBase58check(sha256); +const tron = new TronAddress(); + +export type EncodingConversion = + | { + input: string; + inputType: "tron-base58" | "tron-hex" | "evm" | "public-key"; + valid: true; + tron: string; + tronHex: string; + evm: string; + } + | { + input: string; + inputType: "hex" | "base64" | "base58check"; + valid: true; + hex: string; + base64: string; + base58check: string; + }; + +export function convertEncoding(input: string): EncodingConversion { + const value = input.trim(); + if (!value || /[\p{Cc}\p{Cf}\s]/u.test(value)) { + throw invalid( + "input is empty or contains unsafe whitespace/control characters", + ); + } + + if (/^T[1-9A-HJ-NP-Za-km-z]{25,40}$/.test(value)) { + if (!tron.validate(value)) { + throw invalid("base58 checksum mismatch (typo in the address?)"); + } + return addressView( + value, + "tron-base58", + tronAddressBytes(value).slice(1), + ); + } + if (/^(?:0x)?41[0-9a-fA-F]{40}$/.test(value)) { + const raw = hexToBytes(value.replace(/^0x/, "")); + return addressView(value, "tron-hex", raw.slice(1)); + } + if (/^0x[0-9a-fA-F]{40}$/.test(value)) { + const payload = hexToBytes(value.slice(2)); + const checksum = evmChecksumAddress(payload); + if ( + /[a-f]/.test(value) + && /[A-F]/.test(value) + && value !== checksum + ) { + throw invalid("EVM EIP-55 checksum mismatch"); + } + return addressView(value, "evm", payload); + } + if ( + /^(?:02|03)[0-9a-fA-F]{64}$/.test(value) + || /^04[0-9a-fA-F]{128}$/.test(value) + ) { + const publicKey = hexToBytes(value); + try { + const payload = publicKeyHash20(publicKey); + return { + input: value, + inputType: "public-key", + valid: true, + tron: tron.fromPublicKey(publicKey), + tronHex: `41${bytesToHex(payload)}`, + evm: evmAddressFromPublicKey(publicKey), + }; + } catch { + throw invalid("public key is not a valid secp256k1 point"); + } + } + + let bytes: Uint8Array; + let inputType: "hex" | "base64" | "base58check"; + if (/^(?:0x)?[0-9a-fA-F]+$/.test(value)) { + const hex = value.replace(/^0x/, ""); + if (hex.length % 2 !== 0) { + throw invalid("hex input must contain complete bytes"); + } + bytes = hexToBytes(hex); + inputType = "hex"; + } else { + try { + bytes = base58check.decode(value); + inputType = "base58check"; + } catch { + if (!isCanonicalBase64(value)) { + throw invalid( + "input is not valid hex, Base64, or Base58Check", + ); + } + bytes = Uint8Array.from(Buffer.from(value, "base64")); + inputType = "base64"; + } + } + if (bytes.length === 32) { + throw invalid( + "32-byte input may be a private key and is not accepted on argv", + ); + } + if (bytes.length === 0 || bytes.length > 1024 * 1024) { + throw invalid("decoded input must contain 1 byte to 1 MiB"); + } + return { + input: value, + inputType, + valid: true, + hex: bytesToHex(bytes), + base64: Buffer.from(bytes).toString("base64"), + base58check: base58check.encode(bytes), + }; +} + +function addressView( + input: string, + inputType: "tron-base58" | "tron-hex" | "evm", + payload: Uint8Array, +): EncodingConversion { + const prefixed = new Uint8Array(21); + prefixed[0] = 0x41; + prefixed.set(payload, 1); + return { + input, + inputType, + valid: true, + tron: tronAddressFromBytes(prefixed), + tronHex: bytesToHex(prefixed), + evm: evmChecksumAddress(payload), + }; +} + +function isCanonicalBase64(value: string): boolean { + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + return false; + } + return Buffer.from(value, "base64").toString("base64") === value; +} + +function invalid(reason: string): UsageError { + return new UsageError("invalid_value", reason); +} diff --git a/ts/src/domain/gasfree/gasfree.test.ts b/ts/src/domain/gasfree/gasfree.test.ts new file mode 100644 index 000000000..5e6c9f3f0 --- /dev/null +++ b/ts/src/domain/gasfree/gasfree.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { utils as tronUtils } from "tronweb"; +import { TronAddress } from "../address/index.js"; +import type { GasFreeAuthorization } from "../types/index.js"; +import { + gasFreeDigest, + gasFreeDomainSeparator, + gasFreeMessageHash, + gasFreeTypedData, + normalizeGasFreeSignature, + recoverGasFreeSigner, +} from "./index.js"; + +const DOMAIN = { + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", +}; +const AUTHORIZATION: GasFreeAuthorization = { + token: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + serviceProvider: "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E", + user: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + receiver: "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + value: "25000000", + maxFee: "1500000", + deadline: "1700000060", + version: "1", + nonce: "8", +}; +const SIGNATURE = + "0x580aab9832cf56d8f418711aa55653a02e51fb97a04fcd09c3bd4cd41cd376f73336a48257ca0d74bbcb8f70cc1bf6e310ca31f167a2ef8b2b93317d9f9a68e31b"; + +describe("GasFree Java-compatible TIP-712", () => { + it("matches fixed domain, struct, and final digest vectors", () => { + expect(bytesToHex(gasFreeDomainSeparator(DOMAIN))).toBe( + "31a0a46f427dd040c91835228e4555951bde0a894cae6239869bb680ebc6ebea", + ); + expect(bytesToHex(gasFreeMessageHash(AUTHORIZATION))).toBe( + "e028dcd1dc81a93cb32a9ac32f1799614d4431c83f0ed5c6c2f932b48f22e614", + ); + expect(bytesToHex(gasFreeDigest(DOMAIN, AUTHORIZATION))).toBe( + "006c1bfb7e397bc2975949b80aa099a33a69a9b8835d84a9714723d25652f5ff", + ); + }); + + it("matches TronWeb TIP-712 hashing and recovers the Java r||s||v signer", () => { + const payload = gasFreeTypedData(DOMAIN, AUTHORIZATION); + const digest = tronUtils.typedData.TypedDataEncoder.hash( + payload.domain as never, + payload.types as never, + payload.message, + ); + expect(digest).toBe(`0x${bytesToHex(gasFreeDigest(DOMAIN, AUTHORIZATION))}`); + expect(normalizeGasFreeSignature(SIGNATURE)).toBe(SIGNATURE.slice(2)); + expect(recoverGasFreeSigner(gasFreeDigest(DOMAIN, AUTHORIZATION), SIGNATURE)) + .toBe(AUTHORIZATION.user); + }); + + it("derives the same TRON address from compressed and uncompressed keys", () => { + const privateKey = hexToBytes(`${"00".repeat(31)}01`); + const address = new TronAddress(); + expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, true))) + .toBe(AUTHORIZATION.user); + expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, false))) + .toBe(AUTHORIZATION.user); + }); +}); diff --git a/ts/src/domain/gasfree/index.ts b/ts/src/domain/gasfree/index.ts new file mode 100644 index 000000000..d86620e4b --- /dev/null +++ b/ts/src/domain/gasfree/index.ts @@ -0,0 +1,139 @@ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js"; +import type { + Bytes, + GasFreeAuthorization, + TypedDataPayload, +} from "../types/index.js"; +import { TronAddress, tronAddressBytes } from "../address/index.js"; + +export const GASFREE_DOMAIN_TYPE = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; +export const GASFREE_PERMIT_TYPE = + "PermitTransfer(address token,address serviceProvider,address user,address receiver,uint256 value,uint256 maxFee,uint256 deadline,uint256 version,uint256 nonce)"; +export const GASFREE_DOMAIN_NAME = "GasFreeController"; +export const GASFREE_DOMAIN_VERSION = "V1.0.0"; + +const UTF8 = new TextEncoder(); + +function uint256Word(value: string, field: string): Bytes { + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error(`${field} must be an unsigned decimal integer`); + } + const integer = BigInt(value); + if (integer >= 1n << 256n) throw new Error(`${field} exceeds uint256`); + return hexToBytes(integer.toString(16).padStart(64, "0")); +} + +function addressWord(address: string): Bytes { + const decoded = tronAddressBytes(address); + const result = new Uint8Array(32); + result.set(decoded.slice(1), 12); + return result; +} + +export interface GasFreeDomainInput { + controllerChainId: string; + verifyingContract: string; +} + +/** Java GasFreeApi.getDomainSeparator byte-for-byte equivalent. */ +export function gasFreeDomainSeparator(domain: GasFreeDomainInput): Bytes { + return keccak_256(concatBytes( + keccak_256(UTF8.encode(GASFREE_DOMAIN_TYPE)), + keccak_256(UTF8.encode(GASFREE_DOMAIN_NAME)), + keccak_256(UTF8.encode(GASFREE_DOMAIN_VERSION)), + uint256Word(domain.controllerChainId, "controllerChainId"), + addressWord(domain.verifyingContract), + )); +} + +/** Java GasFreeApi.buildMessage byte-for-byte equivalent. */ +export function gasFreeMessageHash(authorization: GasFreeAuthorization): Bytes { + return keccak_256(concatBytes( + keccak_256(UTF8.encode(GASFREE_PERMIT_TYPE)), + addressWord(authorization.token), + addressWord(authorization.serviceProvider), + addressWord(authorization.user), + addressWord(authorization.receiver), + uint256Word(authorization.value, "value"), + uint256Word(authorization.maxFee, "maxFee"), + uint256Word(authorization.deadline, "deadline"), + uint256Word(authorization.version, "version"), + uint256Word(authorization.nonce, "nonce"), + )); +} + +/** keccak256(0x1901 || domainSeparator || messageHash), matching Java signOffChain input. */ +export function gasFreeDigest( + domain: GasFreeDomainInput, + authorization: GasFreeAuthorization, +): Bytes { + return keccak_256(concatBytes( + Uint8Array.of(0x19, 0x01), + gasFreeDomainSeparator(domain), + gasFreeMessageHash(authorization), + )); +} + +/** Build the exact TIP-712 payload used by software and Ledger signers. */ +export function gasFreeTypedData( + domain: GasFreeDomainInput, + authorization: GasFreeAuthorization, +): TypedDataPayload { + return { + domain: { + name: GASFREE_DOMAIN_NAME, + version: GASFREE_DOMAIN_VERSION, + chainId: domain.controllerChainId, + verifyingContract: domain.verifyingContract, + }, + types: { + PermitTransfer: [ + { name: "token", type: "address" }, + { name: "serviceProvider", type: "address" }, + { name: "user", type: "address" }, + { name: "receiver", type: "address" }, + { name: "value", type: "uint256" }, + { name: "maxFee", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "version", type: "uint256" }, + { name: "nonce", type: "uint256" }, + ], + }, + primaryType: "PermitTransfer", + message: { ...authorization }, + }; +} + +/** Validate low-s r||s||v and return Java-compatible lowercase hex without 0x. */ +export function normalizeGasFreeSignature(signatureHex: string): string { + const normalized = signatureHex.replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{130}$/.test(normalized)) { + throw new Error("GasFree signature must be 65-byte hexadecimal r || s || v"); + } + const raw = hexToBytes(normalized); + const v = raw[64]!; + const recovery = v >= 27 ? v - 27 : v; + if (recovery !== 0 && recovery !== 1) { + throw new Error("GasFree signature recovery id must be 0/1 or 27/28"); + } + const signature = secp256k1.Signature.fromBytes(raw.slice(0, 64), "compact"); + if (signature.hasHighS()) throw new Error("GasFree signature must use low-s form"); + raw[64] = recovery + 27; + return bytesToHex(raw); +} + +export function recoverGasFreeSigner(digest: Bytes, signatureHex: string): string { + if (digest.length !== 32) throw new Error("GasFree digest must be 32 bytes"); + const canonical = hexToBytes(normalizeGasFreeSignature(signatureHex)); + const recovery = canonical[64]! - 27; + const recoveredSignature = concatBytes( + Uint8Array.of(recovery), + canonical.slice(0, 64), + ); + const compressed = secp256k1.recoverPublicKey(recoveredSignature, digest, { prehash: false }); + const uncompressed = secp256k1.Point.fromBytes(compressed).toBytes(false); + return new TronAddress().fromPublicKey(uncompressed); +} diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts new file mode 100644 index 000000000..d3e6bb931 --- /dev/null +++ b/ts/src/domain/permission/index.ts @@ -0,0 +1,391 @@ +import type { + AccountDescriptor, + AccountPermissionsView, + ActivePermissionView, + PermissionGroupView, + PermissionKeyView, + WarningView, +} from "../types/index.js"; +import { UsageError } from "../errors/index.js"; +import { TronAddress } from "../address/index.js"; + +const OPERATIONS_BYTES = 32; +const MAX_KEYS = 5; +const MAX_ACTIVES = 8; +const MAX_PERMISSION_NAME_BYTES = 32; +const addressCodec = new TronAddress(); + +export interface TronOperation { + contractTypeId: number; + contractType: string; + label: string; +} + +/** Authoritative mapping shared by permission bitmaps, display labels, and authorization checks. */ +export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ + { contractTypeId: 0, contractType: "AccountCreateContract", label: "Activate Account" }, + { contractTypeId: 1, contractType: "TransferContract", label: "Transfer TRX" }, + { contractTypeId: 2, contractType: "TransferAssetContract", label: "Transfer TRC10" }, + { contractTypeId: 3, contractType: "VoteAssetContract", label: "Vote for TRC10 [unused]" }, + { contractTypeId: 4, contractType: "VoteWitnessContract", label: "Vote" }, + { contractTypeId: 5, contractType: "WitnessCreateContract", label: "Apply to Become a SR Candidate" }, + { contractTypeId: 6, contractType: "AssetIssueContract", label: "Issue TRC10" }, + { contractTypeId: 8, contractType: "WitnessUpdateContract", label: "Update SR Info" }, + { contractTypeId: 9, contractType: "ParticipateAssetIssueContract", label: "Participate in TRC10 Issuance" }, + { contractTypeId: 10, contractType: "AccountUpdateContract", label: "Update Account Name" }, + { contractTypeId: 11, contractType: "FreezeBalanceContract", label: "TRX Stake (1.0)" }, + { contractTypeId: 12, contractType: "UnfreezeBalanceContract", label: "TRX Unstake (1.0)" }, + { contractTypeId: 13, contractType: "WithdrawBalanceContract", label: "Claim Voting Rewards" }, + { contractTypeId: 14, contractType: "UnfreezeAssetContract", label: "Unstake TRC10" }, + { contractTypeId: 15, contractType: "UpdateAssetContract", label: "Update TRC10 Parameters" }, + { contractTypeId: 16, contractType: "ProposalCreateContract", label: "Create Proposal" }, + { contractTypeId: 17, contractType: "ProposalApproveContract", label: "Approve Proposal" }, + { contractTypeId: 18, contractType: "ProposalDeleteContract", label: "Cancel Proposal" }, + { contractTypeId: 19, contractType: "SetAccountIdContract", label: "Set Account Id" }, + { contractTypeId: 20, contractType: "CustomContract", label: "Custom Contract" }, + { contractTypeId: 30, contractType: "CreateSmartContract", label: "Create Smart Contract" }, + { contractTypeId: 31, contractType: "TriggerSmartContract", label: "Trigger Smart Contract" }, + { contractTypeId: 32, contractType: "GetContract", label: "Get Contract" }, + { contractTypeId: 33, contractType: "UpdateSettingContract", label: "Update Contract Parameters" }, + { contractTypeId: 41, contractType: "ExchangeCreateContract", label: "Create Bancor Transaction" }, + { contractTypeId: 42, contractType: "ExchangeInjectContract", label: "Inject Assets into Bancor Transaction" }, + { contractTypeId: 43, contractType: "ExchangeWithdrawContract", label: "Withdraw Assets from Bancor Transaction" }, + { contractTypeId: 44, contractType: "ExchangeTransactionContract", label: "Execute Bancor Transaction" }, + { contractTypeId: 45, contractType: "UpdateEnergyLimitContract", label: "Update Contract Energy Limit" }, + { contractTypeId: 46, contractType: "AccountPermissionUpdateContract", label: "Update Account Permissions" }, + { contractTypeId: 48, contractType: "ClearABIContract", label: "Clear Contract ABI" }, + { contractTypeId: 49, contractType: "UpdateBrokerageContract", label: "Update SR Commission Ratio" }, + { contractTypeId: 51, contractType: "ShieldedTransferContract", label: "Shielded Transfer" }, + { contractTypeId: 52, contractType: "MarketSellAssetContract", label: "Market Sell Asset" }, + { contractTypeId: 53, contractType: "MarketCancelOrderContract", label: "Market Cancel Order" }, + { contractTypeId: 54, contractType: "FreezeBalanceV2Contract", label: "TRX Stake (2.0)" }, + { contractTypeId: 55, contractType: "UnfreezeBalanceV2Contract", label: "TRX Unstake (2.0)" }, + { contractTypeId: 56, contractType: "WithdrawExpireUnfreezeContract", label: "Withdraw Unstaked TRX" }, + { contractTypeId: 57, contractType: "DelegateResourceContract", label: "Delegate Resources" }, + { contractTypeId: 58, contractType: "UnDelegateResourceContract", label: "Reclaim Resources" }, + { contractTypeId: 59, contractType: "CancelAllUnfreezeV2Contract", label: "Cancel Unstake" }, +]); + +const operationByType = new Map(TRON_OPERATIONS.map((operation) => [operation.contractType, operation])); +const operationById = new Map(TRON_OPERATIONS.map((operation) => [operation.contractTypeId, operation])); + +function invalidPermission(message: string, details?: object): never { + throw new UsageError("invalid_permission", message, details); +} + +function ownRecord(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidPermission(`${field} must be an object`); + } + return value as Record; +} + +function safePositiveInteger(value: unknown, field: string): number { + let text: string; + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) return invalidPermission(`${field} must be a safe integer`); + text = String(value); + } else if (typeof value === "bigint") { + text = value.toString(); + } else if (typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value)) { + text = value; + } else if (value && typeof value === "object" && typeof (value as { toString?: unknown }).toString === "function") { + text = String(value); + } else { + return invalidPermission(`${field} must be an integer`); + } + if (!/^[1-9][0-9]*$/.test(text)) return invalidPermission(`${field} must be greater than zero`); + const integer = BigInt(text); + if (integer > BigInt(Number.MAX_SAFE_INTEGER)) { + return invalidPermission(`${field} exceeds the precise integer range supported by TronWeb`); + } + return Number(integer); +} + +function exactInteger(value: unknown, expected: number, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value !== expected) { + return invalidPermission(`${field} must be ${expected}`); + } + return value; +} + +function permissionName(value: unknown, fallback: string, field: string): string { + const name = value === undefined ? fallback : value; + if (typeof name !== "string" || name.length === 0) return invalidPermission(`${field} must not be empty`); + if (Buffer.byteLength(name, "utf8") > MAX_PERMISSION_NAME_BYTES) { + return invalidPermission(`${field} must be at most ${MAX_PERMISSION_NAME_BYTES} UTF-8 bytes`); + } + if (/\p{Cc}/u.test(name)) return invalidPermission(`${field} must not contain control characters`); + return name; +} + +function keys(value: unknown, field: string, exactCount?: number): PermissionKeyView[] { + if (!Array.isArray(value) + || value.length < 1 + || value.length > MAX_KEYS + || (exactCount !== undefined && value.length !== exactCount)) { + return invalidPermission( + exactCount === undefined + ? `${field} must contain 1 to ${MAX_KEYS} keys` + : `${field} must contain exactly ${exactCount} key`, + ); + } + const seen = new Set(); + return value.map((entry, index) => { + const item = ownRecord(entry, `${field}[${index}]`); + if (typeof item.address !== "string" || !addressCodec.validate(item.address)) { + return invalidPermission(`${field}[${index}].address is not a valid TRON address`); + } + if (seen.has(item.address)) return invalidPermission(`${field} contains a duplicate address`); + seen.add(item.address); + return { + address: item.address, + weight: safePositiveInteger(item.weight, `${field}[${index}].weight`), + local: null, + }; + }); +} + +function group(value: unknown, kind: "owner" | "witness", expectedId: 0 | 1): PermissionGroupView { + const input = ownRecord(value, kind); + if (input.operations !== undefined || input.operationsHex !== undefined) { + return invalidPermission(`${kind} permission must not define operations`); + } + const parsedKeys = keys(input.keys, `${kind}.keys`, kind === "witness" ? 1 : undefined); + const threshold = safePositiveInteger(input.threshold, `${kind}.threshold`); + const total = parsedKeys.reduce((sum, key) => sum + BigInt(key.weight), 0n); + if (BigInt(threshold) > total) return invalidPermission(`${kind}.threshold exceeds the total key weight`); + return { + id: exactInteger(input.id, expectedId, `${kind}.id`), + name: permissionName(input.name, kind, `${kind}.name`), + threshold, + keys: parsedKeys, + }; +} + +export interface DecodedOperations { + operations: string[]; + labels: string[]; + unknownOperationIds: number[]; + operationsHex: string; +} + +export function decodeOperations(input: string): DecodedOperations { + const operationsHex = input.trim().replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(operationsHex)) { + return invalidPermission("operationsHex must be exactly 32 bytes of hex"); + } + const bytes = Buffer.from(operationsHex, "hex"); + const operations: string[] = []; + const labels: string[] = []; + const unknownOperationIds: number[] = []; + for (let id = 0; id < OPERATIONS_BYTES * 8; id += 1) { + if ((bytes[Math.floor(id / 8)]! & (1 << (id % 8))) === 0) continue; + const operation = operationById.get(id); + if (operation) { + operations.push(operation.contractType); + labels.push(operation.label); + } else { + unknownOperationIds.push(id); + } + } + return { operations, labels, unknownOperationIds, operationsHex }; +} + +export function encodeOperations(contractTypes: readonly string[]): string { + if (!Array.isArray(contractTypes) || contractTypes.length === 0) { + return invalidPermission("active.operations must contain at least one contract type"); + } + const bytes = Buffer.alloc(OPERATIONS_BYTES); + const seen = new Set(); + for (const contractType of contractTypes) { + if (typeof contractType !== "string") return invalidPermission("active.operations entries must be strings"); + if (seen.has(contractType)) continue; + seen.add(contractType); + const operation = operationByType.get(contractType); + if (!operation) return invalidPermission(`unknown TRON contract type: ${contractType}`); + bytes[Math.floor(operation.contractTypeId / 8)]! |= 1 << (operation.contractTypeId % 8); + } + return bytes.toString("hex"); +} + +/** Require unnamed bitmap bits to be declared verbatim, so no set bit escapes human review. */ +function assertDeclaredUnknownOperations(value: unknown, actual: readonly number[], index: number): void { + const field = `actives[${index}].unknownOperationIds`; + if (value === undefined) { + if (actual.length === 0) return; + return invalidPermission( + `${field} must declare the unnamed contract types set in operationsHex: ${actual.join(", ")}`, + ); + } + if (!Array.isArray(value) || value.some((id) => typeof id !== "number" || !Number.isSafeInteger(id))) { + return invalidPermission(`${field} must be an array of integers`); + } + const declaredIds = value as number[]; + if (new Set(declaredIds).size !== declaredIds.length) { + return invalidPermission(`${field} must not contain duplicate contract type ids`); + } + const declared = [...declaredIds].sort((a, b) => a - b); + if (declared.length !== actual.length || declared.some((id, position) => id !== actual[position])) { + return invalidPermission( + `${field} does not match operationsHex: declared ${declared.join(", ") || "none"}, bitmap sets ${actual.join(", ") || "none"}`, + ); + } +} + +function activeGroup(value: unknown, index: number): ActivePermissionView { + const input = ownRecord(value, `actives[${index}]`); + if (typeof input.id !== "number" || !Number.isSafeInteger(input.id) || input.id < 2 || input.id > 9) { + return invalidPermission(`actives[${index}].id must be an integer from 2 to 9`); + } + if (!Array.isArray(input.operations)) { + return invalidPermission(`actives[${index}].operations must be an array`); + } + const declaredOperations = input.operations as string[]; + // A node may set bits for contract types this build has no name for. Those bits are real + // permission scope, so `operations` alone cannot describe the bitmap — hence the empty-list case. + const encodedKnownOperations = declaredOperations.length === 0 + ? "00".repeat(OPERATIONS_BYTES) + : encodeOperations(declaredOperations); + let operationsHex = encodedKnownOperations; + if (input.operationsHex !== undefined) { + if (typeof input.operationsHex !== "string") { + return invalidPermission(`actives[${index}].operationsHex does not match operations`); + } + const supplied = decodeOperations(input.operationsHex); + const known = decodeOperations(encodedKnownOperations); + if ( + supplied.operations.length !== known.operations.length + || supplied.operations.some((operation, operationIndex) => operation !== known.operations[operationIndex]) + ) { + return invalidPermission(`actives[${index}].operationsHex does not match operations`); + } + // Every set bit must be declared somewhere a reviewer can read: named types in `operations`, + // unnamed ones in `unknownOperationIds`. Without this, a bitmap can widen the permission + // while the human-readable fields stay unchanged — the failure this whole field pair prevents. + assertDeclaredUnknownOperations(input.unknownOperationIds, supplied.unknownOperationIds, index); + operationsHex = supplied.operationsHex; + } else if (input.unknownOperationIds !== undefined) { + return invalidPermission( + `actives[${index}].unknownOperationIds requires operationsHex — operations cannot express unnamed contract types`, + ); + } + const decoded = decodeOperations(operationsHex); + if (decoded.operations.length === 0 && decoded.unknownOperationIds.length === 0) { + return invalidPermission("active.operations must contain at least one contract type"); + } + const parsedKeys = keys(input.keys, `actives[${index}].keys`); + const threshold = safePositiveInteger(input.threshold, `actives[${index}].threshold`); + const total = parsedKeys.reduce((sum, key) => sum + BigInt(key.weight), 0n); + if (BigInt(threshold) > total) { + return invalidPermission(`actives[${index}].threshold exceeds the total key weight`); + } + return { + id: input.id, + name: permissionName(input.name, "active", `actives[${index}].name`), + threshold, + keys: parsedKeys, + operations: decoded.operations, + operationLabels: decoded.labels, + operationsHex, + unknownOperationIds: decoded.unknownOperationIds, + }; +} + +/** Strictly validate and canonicalize the complete replacement structure. */ +export function validatePermissionStructure(value: unknown, expectedAddress?: string): AccountPermissionsView { + const input = ownRecord(value, "permission structure"); + if (expectedAddress !== undefined && input.address !== undefined && input.address !== expectedAddress) { + return invalidPermission("permission structure address does not match the selected account"); + } + const address = expectedAddress ?? input.address; + if (typeof address !== "string" || !addressCodec.validate(address)) { + return invalidPermission("permission structure address is not a valid TRON address"); + } + const owner = group(input.owner, "owner", 0); + const witness = input.witness === undefined || input.witness === null + ? null + : group(input.witness, "witness", 1); + const activeInput = input.actives ?? []; + if (!Array.isArray(activeInput) || activeInput.length > MAX_ACTIVES) { + return invalidPermission(`actives must contain at most ${MAX_ACTIVES} permission groups`); + } + const actives = activeInput.map(activeGroup); + const ids = new Set(); + for (const active of actives) { + if (ids.has(active.id)) return invalidPermission("active permission ids must be unique"); + ids.add(active.id); + } + return { address, owner, witness, actives }; +} + +export type LocalPermissionInventory = ReadonlyMap; + +/** Watch-only accounts are excluded because they cannot contribute a signature. */ +export function buildLocalPermissionInventory(accounts: readonly AccountDescriptor[]): LocalPermissionInventory { + const inventory = new Map(); + for (const account of accounts) { + if (account.type === "watch") continue; + const address = account.addresses.tron; + if (!address || inventory.has(address)) continue; + inventory.set(address, account.label ?? String(account.accountId)); + } + return inventory; +} + +export function annotateLocalPermissionKeys( + permissions: AccountPermissionsView, + inventory: LocalPermissionInventory, +): AccountPermissionsView { + const annotate = (permission: PermissionGroupView): PermissionGroupView => ({ + ...permission, + keys: permission.keys.map((key) => ({ ...key, local: inventory.get(key.address) ?? null })), + }); + return { + ...permissions, + owner: annotate(permissions.owner), + witness: permissions.witness ? annotate(permissions.witness) : null, + actives: permissions.actives.map((active) => ({ ...active, ...annotate(active) })), + }; +} + +export function permissionSafetyWarnings( + permissions: AccountPermissionsView, + inventory: LocalPermissionInventory, +): WarningView[] { + const localOwnerWeight = permissions.owner.keys.reduce( + (sum, key) => inventory.has(key.address) ? sum + key.weight : sum, + 0, + ); + const warnings: WarningView[] = []; + if (localOwnerWeight === 0) { + warnings.push({ + code: "owner_lockout", + message: "local signing keys hold no owner weight; applying this structure may permanently lock out this wallet", + }); + } else if (localOwnerWeight < permissions.owner.threshold) { + warnings.push({ + code: "owner_lockout_partial", + message: `local keys hold ${localOwnerWeight} of ${permissions.owner.threshold} owner weight; co-signers are required for owner-level operations`, + }); + } + for (const active of permissions.actives) { + if (active.operations.includes("AccountPermissionUpdateContract")) { + warnings.push({ + code: "active_can_update_permission", + message: `active permission ${active.name} (id ${active.id}) can replace the account permission structure`, + }); + } + if (active.unknownOperationIds.length > 0) { + warnings.push({ + code: "active_unknown_operations", + message: `active permission ${active.name} (id ${active.id}) grants contract types this build cannot name: ${active.unknownOperationIds.join(", ")}; their bitmap scope is preserved`, + }); + } + } + return warnings; +} + +export function operationForContractType(contractType: string): TronOperation | undefined { + return operationByType.get(contractType); +} diff --git a/ts/src/domain/permission/permission.test.ts b/ts/src/domain/permission/permission.test.ts new file mode 100644 index 000000000..84c9333f7 --- /dev/null +++ b/ts/src/domain/permission/permission.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import type { AccountDescriptor } from "../types/index.js"; +import { + annotateLocalPermissionKeys, + buildLocalPermissionInventory, + decodeOperations, + encodeOperations, + permissionSafetyWarnings, + validatePermissionStructure, +} from "./index.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const C = "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8"; + +function structure() { + return { + address: A, + owner: { + id: 0, + threshold: 2, + keys: [{ address: A, weight: 1, local: "forged" }, { address: B, weight: 1 }], + }, + witness: null, + actives: [{ + id: 2, + name: "finance", + threshold: 1, + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + operationsHex: "0600008000000000000000000000000000000000000000000000000000000000", + unknownOperationIds: [] as number[], + keys: [{ address: A, weight: 1 }], + }], + }; +} + +describe("TRON permission operations", () => { + it("uses contract ids as little-endian bits within a fixed 32-byte bitmap", () => { + const encoded = encodeOperations(["TransferContract", "TransferAssetContract", "TriggerSmartContract"]); + expect(encoded).toBe("0600008000000000000000000000000000000000000000000000000000000000"); + expect(decodeOperations(encoded)).toMatchObject({ + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + labels: ["Transfer TRX", "Transfer TRC10", "Trigger Smart Contract"], + unknownOperationIds: [], + }); + }); + + it("names every operation enabled by the protocol default bitmap", () => { + const decoded = decodeOperations("7fff1fc0033ef30f" + "00".repeat(24)); + expect(decoded.operations).toContain("VoteAssetContract"); + expect(decoded.operations).toContain("CustomContract"); + expect(decoded.operations).toContain("GetContract"); + expect(decoded.unknownOperationIds).toEqual([]); + }); + + it("recognizes shielded transfer even though the current default bitmap excludes it", () => { + const decoded = decodeOperations("00".repeat(6) + "08" + "00".repeat(25)); + expect(decoded.operations).toEqual(["ShieldedTransferContract"]); + expect(decoded.unknownOperationIds).toEqual([]); + }); + + it("preserves genuinely unknown set bits when reading node state", () => { + const decoded = decodeOperations("80" + "00".repeat(31)); + expect(decoded.operations).toEqual([]); + expect(decoded.unknownOperationIds).toEqual([7]); + }); + + it("rejects unknown contract names and malformed bitmaps", () => { + expect(() => encodeOperations(["FutureContract"])).toThrowError(/unknown TRON contract/); + expect(() => decodeOperations("00")).toThrowError(/exactly 32 bytes/); + }); +}); + +describe("permission replacement validation", () => { + it("canonicalizes a valid structure and ignores forged local labels", () => { + const parsed = validatePermissionStructure(structure()); + expect(parsed.owner.keys[0]?.local).toBeNull(); + expect(parsed.owner.name).toBe("owner"); + expect(parsed.actives[0]?.operationLabels).toContain("Transfer TRX"); + }); + + it("round-trips unknown operation bits when the known operation list matches", () => { + const input = structure(); + input.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + input.actives[0]!.unknownOperationIds = [7]; + const parsed = validatePermissionStructure(input); + expect(parsed.actives[0]).toMatchObject({ + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + operationsHex: input.actives[0]!.operationsHex, + unknownOperationIds: [7], + }); + }); + + // The bitmap is what the chain enforces; `operations` and `unknownOperationIds` are what a human + // reviews. A bit set in the bitmap but absent from both lists would widen the permission with + // nothing in the file to show for it — exactly how the original round-trip bug hid its damage. + it("rejects unnamed operation bits that the file does not declare", () => { + const omitted = structure(); + omitted.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + delete (omitted.actives[0] as Record).unknownOperationIds; + expect(() => validatePermissionStructure(omitted)).toThrowError(/must declare the unnamed contract types.*7/); + + const emptied = structure(); + emptied.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + expect(() => validatePermissionStructure(emptied)).toThrowError(/declared none, bitmap sets 7/); + }); + + it("rejects unknownOperationIds that disagree with the bitmap", () => { + const understated = structure(); + understated.actives[0]!.operationsHex = "86000088" + "00".repeat(28); + understated.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(understated)).toThrowError(/does not match operationsHex/); + + const overstated = structure(); + overstated.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(overstated)).toThrowError(/does not match operationsHex/); + }); + + it("rejects duplicate unknownOperationIds", () => { + const input = structure(); + input.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + input.actives[0]!.unknownOperationIds = [7, 7]; + expect(() => validatePermissionStructure(input)).toThrowError(/must not contain duplicate/); + }); + + it("rejects unknownOperationIds without a bitmap to justify them", () => { + const input = structure(); + delete (input.actives[0] as Record).operationsHex; + input.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(input)).toThrowError(/requires operationsHex/); + + const malformed = structure(); + delete (malformed.actives[0] as Record).operationsHex; + (malformed.actives[0] as Record).unknownOperationIds = "3"; + expect(() => validatePermissionStructure(malformed)).toThrowError(/requires operationsHex/); + }); + + // A node permission may set only bits this build cannot name; `permission show` then emits + // operations: []. Rejecting that would leave such an account unable to restore its own backup. + it("accepts an active whose bitmap holds only unnamed contract types", () => { + const input = structure(); + input.actives[0]!.operations = []; + input.actives[0]!.operationsHex = "80" + "00".repeat(31); + input.actives[0]!.unknownOperationIds = [7]; + const parsed = validatePermissionStructure(input); + expect(parsed.actives[0]).toMatchObject({ + operations: [], + operationsHex: "80" + "00".repeat(31), + unknownOperationIds: [7], + }); + }); + + it("still rejects an active that authorizes nothing at all", () => { + const input = structure(); + input.actives[0]!.operations = []; + input.actives[0]!.operationsHex = "00".repeat(32); + expect(() => validatePermissionStructure(input)).toThrowError(/at least one contract type/); + }); + + it("rejects unsafe thresholds, duplicate ids/addresses, unknown operations, and control names", () => { + const high = structure(); + high.owner.threshold = 3; + expect(() => validatePermissionStructure(high)).toThrowError(/threshold exceeds/); + + const duplicateAddress = structure(); + duplicateAddress.owner.keys[1]!.address = A; + expect(() => validatePermissionStructure(duplicateAddress)).toThrowError(/duplicate address/); + + const duplicateId = structure(); + duplicateId.actives.push({ ...duplicateId.actives[0]!, name: "second" }); + expect(() => validatePermissionStructure(duplicateId)).toThrowError(/ids must be unique/); + + const unknown = structure(); + unknown.actives[0]!.operations = ["FutureContract"]; + expect(() => validatePermissionStructure(unknown)).toThrowError(/unknown TRON contract/); + + const control = structure(); + control.actives[0]!.name = "safe\u001b[31m"; + expect(() => validatePermissionStructure(control)).toThrowError(/control characters/); + }); + + it("matches the Java protocol guard that witness permission has exactly one key", () => { + const input = structure(); + input.witness = { + id: 1, + threshold: 1, + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + } as never; + expect(() => validatePermissionStructure(input)).toThrowError(/exactly 1 key/); + }); + + it("rejects integers outside the exact TronWeb range and another account's export", () => { + const input = structure() as unknown as { owner: { threshold: unknown } }; + input.owner.threshold = "9007199254740992"; + expect(() => validatePermissionStructure(input)).toThrowError(/precise integer range/); + expect(() => validatePermissionStructure(structure(), B)).toThrowError(/does not match/); + }); +}); + +describe("local key inventory and lockout warnings", () => { + const account = (type: AccountDescriptor["type"], address: string, label: string): AccountDescriptor => ({ + accountId: `wlt_${label}` as never, + label, + type, + index: null, + active: false, + addresses: { tron: address }, + }); + + it("excludes watch-only keys, de-duplicates addresses, and ignores forged labels", () => { + const inventory = buildLocalPermissionInventory([ + account("privateKey", A, "main"), + account("ledger", A, "duplicate"), + account("watch", B, "watch"), + ]); + expect([...inventory.entries()]).toEqual([[A, "main"]]); + const annotated = annotateLocalPermissionKeys(validatePermissionStructure(structure()), inventory); + expect(annotated.owner.keys.map((key) => key.local)).toEqual(["main", null]); + expect(permissionSafetyWarnings(annotated, inventory)).toEqual([ + expect.objectContaining({ code: "owner_lockout_partial" }), + ]); + }); + + it("reports complete owner lockout and active permission escalation", () => { + const input = structure(); + input.actives[0]!.operations.push("AccountPermissionUpdateContract"); + input.actives[0]!.operationsHex = encodeOperations(input.actives[0]!.operations); + const warnings = permissionSafetyWarnings(validatePermissionStructure(input), new Map([[C, "other"]])); + expect(warnings.map((warning) => warning.code)).toEqual([ + "owner_lockout", + "active_can_update_permission", + ]); + }); + + // Preserving bits we cannot name is the safe default, but it is still scope the user cannot see + // in the operation list — --dry-run has to say so rather than render an empty warnings array. + it("warns that preserved unnamed operations are scope the reader cannot inspect", () => { + const input = structure(); + input.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + input.actives[0]!.unknownOperationIds = [7]; + const inventory = new Map([[A, "main"]]); + const warnings = permissionSafetyWarnings(validatePermissionStructure(input), inventory); + expect(warnings).toContainEqual(expect.objectContaining({ + code: "active_unknown_operations", + message: expect.stringContaining("7"), + })); + }); +}); diff --git a/ts/src/domain/types/contact.ts b/ts/src/domain/types/contact.ts new file mode 100644 index 000000000..ae6c81604 --- /dev/null +++ b/ts/src/domain/types/contact.ts @@ -0,0 +1,27 @@ +import type { ChainFamily } from "../family/index.js"; + +/** Canonical persisted recipient. nameKey is an integrity-checked lookup key. */ +export interface ContactEntry { + name: string; + nameKey: string; + address: string; + note: string | null; + family: ChainFamily; +} + +/** Public contact projection; storage-only normalization fields never leak. */ +export interface ContactView { + name: string; + address: string; + note: string | null; + family: ChainFamily; +} + +export interface ContactListView { + contacts: ContactView[]; +} + +export interface ResolvedRecipient { + address: string; + contactName?: string; +} diff --git a/ts/src/domain/types/gasfree.ts b/ts/src/domain/types/gasfree.ts new file mode 100644 index 000000000..bf159650f --- /dev/null +++ b/ts/src/domain/types/gasfree.ts @@ -0,0 +1,135 @@ +export type GasFreeState = "WAITING" | "INPROGRESS" | "CONFIRMING" | "SUCCEED" | "FAILED"; + +export interface GasFreeTokenConfig { + tokenAddress: string; + activateFee: string; + transferFee: string; + symbol?: string; + decimals?: number; +} + +export interface GasFreeProviderConfig { + address: string; + defaultDeadlineDuration: string; + isDefault?: boolean; +} + +export interface GasFreeAssetConfig { + tokenAddress: string; + activateFee: string; + transferFee: string; +} + +export interface GasFreeAddressInfo { + ownerAddress: string; + gasFreeAddress: string; + active: boolean; + nonce: string; + allowSubmit?: boolean; + assets: GasFreeAssetConfig[]; +} + +/** Exact immutable TIP-712 PermitTransfer fields. uint256 values remain decimal strings. */ +export interface GasFreeAuthorization { + token: string; + serviceProvider: string; + user: string; + receiver: string; + value: string; + maxFee: string; + deadline: string; + version: string; + nonce: string; +} + +export interface SignedGasFreeAuthorization extends GasFreeAuthorization { + /** Java-compatible lowercase r || s || v hex without a 0x prefix. */ + sig: string; +} + +/** Untrusted provider receipt, normalized losslessly before application-layer validation. */ +export interface GasFreeTransferRecord { + id: string; + state: GasFreeState; + tokenAddress: string; + providerAddress: string; + accountAddress: string; + gasFreeAddress: string; + targetAddress: string; + amount: string; + maxFee?: string; + nonce: string; + /** GasFree returns this timestamp in milliseconds, while the signed deadline is in seconds. */ + expiredAt?: string; + estimatedTransferFee?: string; + estimatedActivateFee?: string; + estimatedTotalFee?: string; + estimatedTotalCost?: string; + txnHash?: string; + txnState?: string; + txnAmount?: string; + txnTransferFee?: string; + txnActivateFee?: string; + txnTotalFee?: string; + txnTotalCost?: string; + txnBlockNum?: string; + txnBlockTimestamp?: string; + failureReason?: string; +} + +export interface GasFreeInfoView { + ownerAddress: string; + gasFreeAddress: string; + active: boolean; + nonce: string; + tokens: Array<{ + symbol: string; + address: string; + decimals: number; + activateFee: string; + transferFee: string; + balance: string; + }>; +} + +export interface GasFreeTransferView { + kind: "gasfree-transfer"; + stage: "dry-run" | "submitted" | "confirmed" | "failed"; + traceId?: string; + state?: GasFreeState; + txId?: string; + token: string; + tokenAddress: string; + decimals: number; + amount: string; + serviceFee: string; + activateFee: string; + authorizedMaxFee: string; + totalDeducted: string; + owner: string; + from: string; + to: string; + toContact?: string; + serviceProvider: string; + nonce: string; + deadline: string; + failureReason?: string; +} + +export interface GasFreeTraceView { + traceId: string; + state: GasFreeState; + txId?: string; + token: string; + tokenAddress: string; + decimals: number; + amount: string; + serviceFee: string; + activateFee: string; + totalDeducted: string; + from: string; + owner: string; + to: string; + nonce: string; + failureReason?: string; +} diff --git a/ts/src/domain/types/index.ts b/ts/src/domain/types/index.ts index 8efa3534e..4d5fed4e8 100644 --- a/ts/src/domain/types/index.ts +++ b/ts/src/domain/types/index.ts @@ -27,4 +27,8 @@ export * from "./wallet.js"; export * from "./token.js"; export * from "./keystore.js"; export * from "./tx.js"; +export * from "./permission.js"; +export * from "./multisig.js"; +export * from "./gasfree.js"; +export * from "./contact.js"; export * from "./primitives.js"; diff --git a/ts/src/domain/types/multisig.ts b/ts/src/domain/types/multisig.ts new file mode 100644 index 000000000..3af7efc7e --- /dev/null +++ b/ts/src/domain/types/multisig.ts @@ -0,0 +1,130 @@ +export interface ApprovedSignerView { + address: string; + weight: number; +} + +export interface TxApprovalView { + txId: string; + contractType: string; + operation?: string; + from?: string; + to?: string; + rawAmount?: string; + tokenContract?: string; + permission: { + id: number; + name: string; + threshold: number; + }; + currentWeight: number; + missingWeight: number; + thresholdReached: boolean; + approved: ApprovedSignerView[]; + expiration: number; + expired: boolean; + signatures: number; +} + +export interface TxSignTransactionView { + txId: string; + contractType: string; + operation?: string; + from?: string; + to?: string; + rawAmount?: string; + tokenContract?: string; + permissionId: number; + expiration: number; + expired: boolean; + signatures: number; +} + +interface TxSignViewBase { + kind: "tx-sign"; + signer: string; + hex: string; + out?: string; + transaction: TxSignTransactionView; +} + +export interface OfflineTxSignView extends TxSignViewBase { + checked: false; +} + +export interface CheckedTxSignView extends TxSignViewBase { + checked: true; + signerWeight: number; + approval: TxApprovalView; +} + +export type TxSignView = OfflineTxSignView | CheckedTxSignView; + +export type TronLinkMultisigState = "pending" | "signed" | "success" | "failed"; + +export interface TronLinkSignatureProgressView { + address: string; + weight: number; + signed: boolean; + signedAt: number | null; +} + +/** Validated projection of an untrusted TronLink collaboration record. */ +export interface TronLinkMultisigTransactionView { + txId: string; + state: TronLinkMultisigState; + contractType: string; + originator: string; + owner: string; + permission: { + id: number; + name: string; + threshold: number; + }; + currentWeight: number; + missingWeight: number; + thresholdReached: boolean; + awaitingMySignature: boolean; + signedByCurrentAccount: boolean; + createdAt: number; + expiration: number; + expired: boolean; + signatures: number; + signatureProgress: TronLinkSignatureProgressView[]; + from?: string; + to?: string; + rawAmount?: string; +} + +export interface TronLinkMultisigListView { + address: string; + total: number; + transactions: TronLinkMultisigTransactionView[]; +} + +export interface TronLinkMultisigCreateView { + action: "create"; + accepted: true; + hex: string; + transaction: TxApprovalView; +} + +export interface TronLinkMultisigSignView { + action: "sign"; + accepted: true; + signer: string; + signerWeight: number; + hex: string; + transaction: TxApprovalView; +} + +export interface TronLinkMultisigWatchView { + action: "watch"; + address: string; + notifications: number; +} + +export type TronLinkMultisigView = + | TronLinkMultisigListView + | TronLinkMultisigCreateView + | TronLinkMultisigSignView + | TronLinkMultisigWatchView; diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index b83dc51b8..f690131c7 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -22,6 +22,10 @@ interface NetworkBase { export interface TronNetworkDescriptor extends NetworkBase { family: "tron"; httpEndpoint?: string; + /** Official walletadapter multi-sign service. Credentials are stored separately in Config. */ + tronlinkHttpEndpoint?: string; + /** Official GasFree service plus the immutable TIP-712 controller domain. */ + gasfree?: GasFreeNetworkConfig; } /** Single family today (TRON). Kept as a named alias so adding a family later means re-introducing @@ -43,6 +47,22 @@ export interface Config { networks: Record; /** USD-valuation source for `account portfolio`. Missing → builtin CoinGecko. */ price?: PriceConfig; + /** TronLink collaboration credentials for the currently selected service environment. */ + tronlinkSecretId?: string; + tronlinkSecretKey?: string; + tronlinkChannel?: string; + /** GasFree Open Platform credentials. The secret is never rendered in clear text. */ + gasfreeApiKey?: string; + gasfreeApiSecret?: string; +} + +export interface GasFreeNetworkConfig { + /** HTTPS origin only; request paths are appended by the GasFree adapter. */ + baseUrl: string; + apiPrefix: string; + /** Decimal uint256 value to avoid passing chain identifiers through floating point. */ + controllerChainId: string; + verifyingContract: string; } /** price service config ; best-effort — failures never fail a balance read. */ diff --git a/ts/src/domain/types/permission.ts b/ts/src/domain/types/permission.ts new file mode 100644 index 000000000..9980aa818 --- /dev/null +++ b/ts/src/domain/types/permission.ts @@ -0,0 +1,31 @@ +export interface WarningView { + code: string; + message: string; +} + +export interface PermissionKeyView { + address: string; + weight: number; + local: string | null; +} + +export interface PermissionGroupView { + id: number; + name: string; + threshold: number; + keys: PermissionKeyView[]; +} + +export interface ActivePermissionView extends PermissionGroupView { + operations: string[]; + operationLabels: string[]; + operationsHex: string; + unknownOperationIds: number[]; +} + +export interface AccountPermissionsView { + address: string; + owner: PermissionGroupView; + witness: PermissionGroupView | null; + actives: ActivePermissionView[]; +} diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 5dcabaaa0..6950900fe 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -20,6 +20,26 @@ export interface TypedDataSignature { primaryType: string; } +/** Lossless JSON projection of one complete TRON protocol.Transaction artifact. */ +export interface TronTransactionArtifact { + visible?: boolean; + txID: string; + raw_data: { + contract: Array<{ + type: string; + Permission_id?: number; + parameter?: { value?: Record; type_url?: string }; + [key: string]: unknown; + }>; + expiration?: number; + timestamp?: number; + [key: string]: unknown; + }; + raw_data_hex: string; + signature?: string[]; + [key: string]: unknown; +} + export interface BroadcastResult { txId?: string; hash?: string; @@ -32,8 +52,9 @@ export type BroadcastStage = "submitted" | "confirmed" | "failed"; export type TxOutcome = | { stage: "plan"; tx: UnsignedTx; fee: FeeReport } + | { stage: "built"; tx: UnsignedTx; hex: string; fee: FeeReport } // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. - | { stage: "signed"; signed: SignedTx; fee?: FeeReport; address?: string; txId?: string } + | { stage: "signed"; signed: SignedTx; hex?: string; fee?: FeeReport; address?: string; txId?: string } | ({ stage: BroadcastStage } & BroadcastResult); // ════════════════════ per-command typed text outputs ══════════════════════ @@ -67,7 +88,8 @@ export type TxReceiptKind = | "send" | "broadcast" | "sign" | "stake-freeze" | "stake-unfreeze" | "stake-delegate" | "stake-undelegate" | "stake-withdraw" | "stake-cancel" | "contract-send" | "contract-deploy" - | "vote-cast" | "reward-withdraw"; + | "vote-cast" | "reward-withdraw" | "permission-update" + | "account-activate" | "account-set"; /** * Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages). @@ -77,16 +99,22 @@ export type TxReceiptKind = */ export interface TxReceiptView { kind: TxReceiptKind; - mode?: "dry-run" | "sign-only"; + mode?: "dry-run" | "build-only" | "sign-only"; stage?: BroadcastStage; txId?: string; hash?: string; // plan / sign-only /** address that produced the signature (sign-only outcomes). */ address?: string; + payer?: string; + field?: "name" | "id"; + value?: string; fee?: FeeReport; tx?: UnsignedTx; signed?: SignedTx; + hex?: string; + transaction?: import("./multisig.js").TxApprovalView; + multiSignFeeSun?: number; // transfer / stake inputs rawAmount?: string; amountSun?: string | number; @@ -95,6 +123,7 @@ export interface TxReceiptView { assetId?: string; decimals?: number; to?: string; + toContact?: string; receiver?: string; resource?: string; votes?: Array<{ witness: string; count: number }>; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 05843d628..03870d284 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -60,7 +60,7 @@ describe("golden CLI — meta & introspection", () => { it("--version prints the version, exit 0", () => { const r = run(["--version"]) expect(r.status).toBe(0) - expect(r.stdout.trim()).toBe("4.10.1") + expect(r.stdout.trim()).toBe("4.11.0") }) it("root --help shows the TRON first-release command surface", () => { @@ -76,6 +76,12 @@ describe("golden CLI — meta & introspection", () => { expect(r.stdout).toContain(" import") expect(r.stdout).toContain(" account") expect(r.stdout).toContain(" tx") + expect(r.stdout).toMatch(/^ permission\s/m) + expect(r.stdout).toMatch(/^ gasfree\s/m) + expect(r.stdout).toMatch(/^ encoding\s/m) + expect(r.stdout).toMatch(/^ address\s/m) + expect(r.stdout).toMatch(/^ contact\s/m) + expect(r.stdout).toContain("current account (--qr for a receive QR code)") expect(r.stdout).not.toContain("Learn more:") expect(r.stdout).not.toMatch(/^ import watch\s/m) expect(r.stdout).not.toMatch(/^ account balance\s/m) @@ -120,7 +126,7 @@ describe("golden CLI — meta & introspection", () => { expect(r.json.aliases).toBeUndefined() const cmd = r.json.commands.find((c: { id: string }) => c.id === "tx.send") expect(cmd.usage).toBe("wallet-cli tx send [options]") - expect(cmd.requires).toMatchObject({ network: "optional", auth: "required", wallet: "optional" }) + expect(cmd.requires).toMatchObject({ network: "optional", auth: "conditional", wallet: "optional" }) expect(cmd.inputSchema.properties.to).toBeDefined() const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic") // TTY-only setup op: the mnemonic is entered interactively, so there is no --*-stdin input flag. @@ -523,7 +529,7 @@ describe("golden CLI — token address-book (local, no RPC)", () => { const r = run(["--output", "json", "token", "list"]) expect(r.status).toBe(0) expect(r.json.data.network).toBe("tron:mainnet") - expect(r.json.data.tokens.map((t: { symbol: string }) => t.symbol)).toEqual(["USDT", "USDC"]) + expect(r.json.data.tokens.map((t: { symbol: string }) => t.symbol)).toEqual(["USDT", "USDC", "USDD"]) expect(r.json.data.tokens.every((t: { source: string }) => t.source === "official")).toBe(true) }) @@ -535,13 +541,12 @@ describe("golden CLI — token address-book (local, no RPC)", () => { expect(r.json.chain.network).toBe("tron:mainnet") }) - it("token list shows a user-added token tagged user (nile, empty official layer)", () => { + it("token list lists nile's official tokens first, then a user-added token tagged user", () => { const ref = seedWallet() seedToken("tron:nile", ref, CUSTOM) const r = run(["--output", "json", "token", "list", "--network", "tron:nile"]) expect(r.status).toBe(0) - expect(r.json.data.tokens).toHaveLength(1) - expect(r.json.data.tokens[0]).toMatchObject({ symbol: "CUS", source: "user" }) + expect(r.json.data.tokens.map((t: { source: string; symbol: string }) => `${t.source}:${t.symbol}`)).toEqual(["official:USDT", "official:USDD", "user:CUS"]) }) it("token remove of an official token → token_is_official, exit 2", () => { diff --git a/ts/test/standalone-node-hid-resolution.test.ts b/ts/test/standalone-node-hid-resolution.test.ts new file mode 100644 index 000000000..28c624877 --- /dev/null +++ b/ts/test/standalone-node-hid-resolution.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveNodeHidAddon } from "../scripts/standalone/resolve-node-hid-addon.js"; + +const TRANSPORT = join("@ledgerhq", "hw-transport-node-hid-noevents"); +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("standalone node-hid resolution", () => { + it("uses the addon visible to Ledger transport instead of a hoisted top-level version", () => { + const root = fixtureRoot(); + installPackage(root, join("node_modules", "node-hid"), "3.4.0", "HID.node"); + const nestedNodeHid = join("node_modules", TRANSPORT, "node_modules", "node-hid"); + installPackage(root, nestedNodeHid, "2.1.2", "HID.node"); + + const resolved = resolveNodeHidAddon({ + platform: "darwin", + rootUrl: join(root, "entry.mjs"), + }); + + expect(resolved.nodeHidVersion).toBe("2.1.2"); + expect(resolved.nativeAddon).toBe( + realpathSync(join(root, nestedNodeHid, "build", "Release", "HID.node")), + ); + }); + + it("selects hidraw on Linux", () => { + const root = fixtureRoot(); + const nestedNodeHid = join("node_modules", TRANSPORT, "node_modules", "node-hid"); + installPackage(root, nestedNodeHid, "2.1.2", "HID_hidraw.node"); + + const resolved = resolveNodeHidAddon({ + platform: "linux", + rootUrl: join(root, "entry.mjs"), + }); + + expect(resolved.nativeAddon).toBe( + realpathSync(join(root, nestedNodeHid, "build", "Release", "HID_hidraw.node")), + ); + }); + + it("rejects an unsupported node-hid major", () => { + const root = fixtureRoot(); + installPackage(root, join("node_modules", "node-hid"), "3.4.0", "HID.node"); + + expect(() => + resolveNodeHidAddon({ platform: "darwin", rootUrl: join(root, "entry.mjs") }), + ).toThrow(/unsupported node-hid 3\.4\.0/); + }); +}); + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), "wallet-cli-node-hid-resolution-")); + temporaryRoots.push(root); + writePackageJson(join(root, "node_modules", TRANSPORT), "6.35.4"); + return root; +} + +function installPackage(root: string, relativePackage: string, version: string, addon: string): void { + const packageRoot = join(root, relativePackage); + writePackageJson(packageRoot, version); + const addonDirectory = join(packageRoot, "build", "Release"); + mkdirSync(addonDirectory, { recursive: true }); + writeFileSync(join(addonDirectory, addon), "fixture"); +} + +function writePackageJson(packageRoot: string, version: string): void { + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "fixture", version })); +} diff --git a/ts/tsup.config.ts b/ts/tsup.config.ts index 24e666a7f..8dbc768fa 100644 --- a/ts/tsup.config.ts +++ b/ts/tsup.config.ts @@ -12,12 +12,12 @@ export default defineConfig({ // through esbuild, which rewrites those specifiers. noExternal: [/@ledgerhq\//], // Kept external, resolved from node_modules at runtime: - // - node-hid / usb: native .node addons esbuild cannot bundle. + // - node-hid: native .node addon esbuild cannot bundle. // - axios: a CJS dep dragged in by @ledgerhq's Speculos transport. Bundling it // into ESM turns its require("util")/require("http") into esbuild's __require // shim, which throws (`Dynamic require of "util" is not supported`) because an // ESM module has no `require`. Left external, Node loads it natively (real // require), so no shim/banner is needed. Declared in dependencies + pinned via // overrides to ^1.18.1 (post-2026-03 supply-chain-safe). - external: ["node-hid", "usb", "axios"], + external: ["node-hid", "axios"], });