diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml new file mode 100644 index 000000000..7898a64a1 --- /dev/null +++ b/.github/workflows/ts-ci.yml @@ -0,0 +1,63 @@ +name: TypeScript CI + +on: + pull_request: + branches: + - "**" + push: + branches: + - "**" + 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..4d31ad40b --- /dev/null +++ b/.github/workflows/ts-standalone-release.yml @@ -0,0 +1,228 @@ +name: TypeScript Standalone Artifacts + +on: + push: + branches: + - master + 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/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index 311e8fe3d..1aec9905a 100644 --- a/ts/docs/commands/account/activate.md +++ b/ts/docs/commands/account/activate.md @@ -26,8 +26,8 @@ Requires the payer account and the master password via `--password-stdin`; watch | `--dry-run` | Build and estimate only; no signature/broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex (feed [`tx broadcast`](../tx/broadcast.md)). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md)). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with (default `0`) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index 5f9f6789c..8f0e046b6 100644 --- a/ts/docs/commands/account/set.md +++ b/ts/docs/commands/account/set.md @@ -27,8 +27,8 @@ Requires the account and the master password via `--password-stdin`; watch-only | `--dry-run` | Build and estimate only; no signature/broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex (feed [`tx broadcast`](../tx/broadcast.md)). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md)). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with (default `0`) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index 751d95e4d..1b4d15b08 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -30,7 +30,7 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index e410d639a..287c24240 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -33,7 +33,7 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index 7a916905c..debd2a28b 100644 --- a/ts/docs/commands/permission/update.md +++ b/ts/docs/commands/permission/update.md @@ -43,8 +43,8 @@ Changing only `keys`, `threshold` or `name` needs no such deletion. | `--dry-run` | Mock receipt — fee, resulting-structure card, and warnings — matching a real submission; no signature, no broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex without broadcasting (feed [`tx broadcast`](../tx/broadcast.md) for on-chain co-signing). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md) for service-relayed multi-sig). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with — changing permissions is owner-level, so normally `0` (default `0`) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); changing permissions normally uses `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/reward/withdraw.md b/ts/docs/commands/reward/withdraw.md index 4d5134ff2..cbc940746 100644 --- a/ts/docs/commands/reward/withdraw.md +++ b/ts/docs/commands/reward/withdraw.md @@ -25,7 +25,7 @@ Moves your accumulated voting rewards (plus block rewards if you are an SR) into | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/cancel-unfreeze.md b/ts/docs/commands/stake/cancel-unfreeze.md index 60d990eeb..475ef2b72 100644 --- a/ts/docs/commands/stake/cancel-unfreeze.md +++ b/ts/docs/commands/stake/cancel-unfreeze.md @@ -23,7 +23,7 @@ Cancels **every** unstake still in its waiting period and rolls those amounts ba | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/delegate.md b/ts/docs/commands/stake/delegate.md index 8d77a8773..33d3b59c5 100644 --- a/ts/docs/commands/stake/delegate.md +++ b/ts/docs/commands/stake/delegate.md @@ -33,7 +33,7 @@ Check how much you can still delegate with [`stake delegated`](delegated.md) (`M | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/freeze.md b/ts/docs/commands/stake/freeze.md index 4071420ef..1118ba65e 100644 --- a/ts/docs/commands/stake/freeze.md +++ b/ts/docs/commands/stake/freeze.md @@ -27,7 +27,7 @@ Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/undelegate.md b/ts/docs/commands/stake/undelegate.md index aab2f0dbc..5e313addb 100644 --- a/ts/docs/commands/stake/undelegate.md +++ b/ts/docs/commands/stake/undelegate.md @@ -29,7 +29,7 @@ Reclaiming is immediate (no waiting period — the TRX was staked all along, onl | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/unfreeze.md b/ts/docs/commands/stake/unfreeze.md index f24b150fd..43ad8cfc5 100644 --- a/ts/docs/commands/stake/unfreeze.md +++ b/ts/docs/commands/stake/unfreeze.md @@ -27,7 +27,7 @@ Stake 2.0 allows at most **32 pending unstakes** per account at a time; check re | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/withdraw.md b/ts/docs/commands/stake/withdraw.md index bff901a40..3cff26c4a 100644 --- a/ts/docs/commands/stake/withdraw.md +++ b/ts/docs/commands/stake/withdraw.md @@ -25,7 +25,7 @@ Withdrawing also frees up unstake slots (max 32 pending unstakes per account). | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index 7087e1697..c7338ae41 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -42,8 +42,8 @@ Requires an account and the master password via `--password-stdin` — signing c | `--dry-run` | Build and estimate only; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000; on cap returns the submitted receipt) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/vote/cast.md b/ts/docs/commands/vote/cast.md index 05abfbece..5c33138f9 100644 --- a/ts/docs/commands/vote/cast.md +++ b/ts/docs/commands/vote/cast.md @@ -31,7 +31,7 @@ Votes take effect at the next maintenance cycle (~6 h). Each vote uses 1 TP (it | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin (fd 0) | diff --git a/ts/package-lock.json b/ts/package-lock.json index 16a9c2092..b6343c2cd 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -10,7 +10,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", @@ -35,6 +35,7 @@ "@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", @@ -617,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", @@ -723,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", @@ -1503,12 +1712,6 @@ "@types/node": "*" } }, - "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/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1858,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", @@ -3234,12 +3478,6 @@ "node": ">=8" } }, - "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/lossless-json": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz", @@ -3387,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", @@ -4589,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", diff --git a/ts/package.json b/ts/package.json index d92364000..b32ba5a48 100644 --- a/ts/package.json +++ b/ts/package.json @@ -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", @@ -78,6 +79,7 @@ "@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/src/adapters/inbound/cli/input/prompt/index.ts b/ts/src/adapters/inbound/cli/input/prompt/index.ts index 6b0a02975..f5166c3f3 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/index.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/index.ts @@ -131,6 +131,10 @@ export class TtyBackend implements PromptBackend { #pendingKey?: (key: KeyEvent) => void; #keyListener?: (s: string, key: KeyEvent) => void; constructor() { + if (process.env.WALLET_CLI_NO_TTY === "1") { + this.#tty = false; + return; + } // Probe for a controlling terminal without holding the fd; the real stream opens on first prompt. try { closeSync(openSync("/dev/tty", "r")); 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..095dbe62a 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,5 @@ import { describe, it, expect } from "vitest"; -import { Prompter, type PromptBackend, type KeyEvent } from "./index.js"; +import { createPrompter, Prompter, type PromptBackend, type KeyEvent } from "./index.js"; class FakeBackend implements PromptBackend { out = ""; @@ -25,6 +25,19 @@ describe("Prompter.setInteractive", () => { }); }); +describe("createPrompter", () => { + it("can force non-interactive mode for subprocess tests", () => { + const previous = process.env.WALLET_CLI_NO_TTY; + process.env.WALLET_CLI_NO_TTY = "1"; + try { + expect(createPrompter().isTTY()).toBe(false); + } finally { + if (previous === undefined) delete process.env.WALLET_CLI_NO_TTY; + else process.env.WALLET_CLI_NO_TTY = previous; + } + }); +}); + describe("Prompter.text", () => { it("re-prompts until validate passes", async () => { const be = new FakeBackend(["", " ", "ok"]); diff --git a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts index 0b8215793..5a010c95b 100644 --- a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import { buildCli, type ShellOptions } from "./index.js"; import { isChainCommand, type SessionRef } from "../contracts/index.js"; import { composeCliRuntime } from "../../../../bootstrap/composition.js"; +import { Prompter } from "../input/prompt/index.js"; +import { SecretResolver } from "../input/secret/index.js"; /** * The other shell tests drive synthetic command definitions, which proves the mechanism but not @@ -31,11 +33,22 @@ describe("every registered positional command rejects its -- spelling", ( // fresh per invocation: StreamManager permits one result emission, so a runtime cannot be shared // across commands that actually run. function newRuntime() { - return composeCliRuntime({ + const runtime = composeCliRuntime({ globals: { output: "json", verbose: false }, secretPaths: {}, startedAt: Date.now(), }); + const prompter = new Prompter({ + isTTY: () => false, + async question() { return ""; }, + async readKey() { return { name: "return" }; }, + write() {}, + beginRaw() {}, + endRaw() {}, + }); + runtime.deps.prompter = prompter; + runtime.deps.secrets = new SecretResolver(runtime.streams, {}, prompter); + return runtime; } function shellOpts(): ShellOptions { diff --git a/ts/src/adapters/outbound/ledger/index.test.ts b/ts/src/adapters/outbound/ledger/index.test.ts index b57c16175..daab168d2 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 0ef328447..7715314ee 100644 --- a/ts/src/adapters/outbound/ledger/index.ts +++ b/ts/src/adapters/outbound/ledger/index.ts @@ -61,7 +61,7 @@ async function openTransport(): Promise<{ transport: unknown; close: () => Promi }); return { transport, close: () => transport.close() }; } - const Hid = unwrap(await import("@ledgerhq/hw-transport-node-hid")); + 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/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index cc385e6d4..66ee0df78 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -20,7 +20,7 @@ import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.j // RUN_LIVE_BROADCAST=1 → actually deploy + confirm on Nile (spends testnet TRX) const HERE = dirname(fileURLToPath(import.meta.url)); -const TSX = join(process.cwd(), "node_modules", ".bin", "tsx"); +const NODE = process.execPath; const ENTRY = join(process.cwd(), "src", "index.ts"); const PW = "testpw123A"; @@ -62,11 +62,11 @@ function deploy( ]; if (opts.dryRun) local.push("--dry-run"); local.push("--password-stdin"); - const r = spawnSync(TSX, [ENTRY, ...globals, ...local], { + const r = spawnSync(NODE, ["--import", "tsx", ENTRY, ...globals, ...local], { input: PW + "\n", encoding: "utf8", - env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1" }, - timeout: opts.timeoutMs ?? 30_000, + env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1", WALLET_CLI_NO_TTY: "1" }, + timeout: opts.timeoutMs ?? 18_000, }); return JSON.parse(r.stdout); } diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 03870d284..ca8ded4f7 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -8,7 +8,7 @@ import { TokenBook } from "../src/adapters/outbound/tokenbook/index.js" import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js" import type { TokenEntry } from "../src/domain/types/index.js" -const TSX = join(process.cwd(), "node_modules", ".bin", "tsx") +const NODE = process.execPath const ENTRY = join(process.cwd(), "src", "index.ts") const MNEMONIC = "test test test test test test test test test test test junk" const TRON1 = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7" @@ -23,7 +23,7 @@ beforeEach(() => { // interactive so it can't run as a black-box subprocess — wallet setup uses seedWallet() to write // the keystore in-process instead. No MASTER_PASSWORD env. password:null → no source (auth_required). function run(args: string[], opts: { input?: string; password?: string | null } = {}) { - const env: Record = { ...process.env, WALLET_CLI_HOME: HOME } as Record + const env: Record = { ...process.env, WALLET_CLI_HOME: HOME, WALLET_CLI_NO_TTY: "1" } as Record delete env.MASTER_PASSWORD const finalArgs = [...args] let stdin = opts.input @@ -31,9 +31,9 @@ function run(args: string[], opts: { input?: string; password?: string | null } finalArgs.push("--password-stdin") stdin = (opts.password ?? DEFAULT_PW) + "\n" } - // 25s < the suite's 30s testTimeout: a genuinely hung subprocess errors here with a clear + // 18s < the suite's 20s testTimeout: a genuinely hung subprocess errors here with a clear // signal instead of silently eating the whole test budget. - const r = spawnSync(TSX, [ENTRY, ...finalArgs], { input: stdin, encoding: "utf8", env, timeout: 25_000 }) + const r = spawnSync(NODE, ["--import", "tsx", ENTRY, ...finalArgs], { input: stdin, encoding: "utf8", env, timeout: 18_000 }) let json: any try { json = JSON.parse(r.stdout) @@ -235,7 +235,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { const again = run(["--output", "json", "backup", "main", "--out", out]) expect(again.status).toBe(2) expect(again.json.error.code).toBe("output_exists") - }, 15000) // seed encrypt + two backup decrypts run scrypt 3× → exceeds vitest's 5s default + }, 20_000) // seed encrypt + two backup decrypts run scrypt 3× → exceeds vitest's 5s default it("supports root-level use and backup account commands", () => { seedWallet() 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"], }); diff --git a/ts/vitest.config.ts b/ts/vitest.config.ts index d7334be5b..9c8dafd18 100644 --- a/ts/vitest.config.ts +++ b/ts/vitest.config.ts @@ -9,19 +9,21 @@ export default defineConfig({ name: "unit", environment: "node", include: ["src/**/*.test.ts"], + testTimeout: 20_000, + hookTimeout: 20_000, }, }, { - // Golden tests spawn a fresh `tsx src/index.ts` per case, which cold-transpiles the + // Golden tests spawn a fresh `node --import tsx src/index.ts` per case, which cold-transpiles the // whole CLI import graph each time. Under parallel CPU load a single spawn can take far // longer than vitest's default 5s testTimeout, causing intermittent timeout failures. - // Give this suite generous timeouts so transient slowness doesn't flake the run. + // Keep this suite above the child-process guard so hangs fail with subprocess details. test: { name: "golden", environment: "node", include: ["test/**/*.test.ts"], - testTimeout: 30_000, - hookTimeout: 30_000, + testTimeout: 20_000, + hookTimeout: 20_000, }, }, ],