diff --git a/README.md b/README.md index 84bf02a..30437b6 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,14 @@ permaweb-deploy deploy --arns-name my-app --sig-type ethereum --private-key "0x. ### Bundler service -Uploads go through a [Turbo](https://docs.ardrive.io/docs/turbo/) **bundler service** (HTTP API that bundles data for Arweave). By default, permaweb-deploy uses ArDrive’s production bundler (`https://upload.ardrive.io`). **`--uploader`** sets the **base URL** of the bundler service to use (scheme + host; typically no path). +Uploads go through a bundler service that accepts signed data items and posts them to Arweave. By default, permaweb-deploy uses the [Turbo](https://docs.ardrive.io/docs/turbo/) API and ArDrive’s production bundler (`https://upload.ardrive.io`). **`--uploader`** sets the **base URL** of the bundler service to use (scheme + host; typically no path). -| When to use | Example value | -| ------------------------- | ------------------------------------------------------------- | -| **Default** (omit flag) | ArDrive production bundler — same as Turbo CLI defaults | -| **Arweave bundler** | `https://up.arweave.net` | -| **Development / staging** | `https://upload.ardrive.dev` | -| **Custom or self-hosted** | Your own base URL if it implements the Turbo bundler protocol | +| When to use | Example value | +| ------------------------- | ----------------------------------------------------------------- | +| **Default** (omit flag) | ArDrive production bundler — same as Turbo CLI defaults | +| **Arweave bundler** | `https://up.arweave.net` | +| **Development / staging** | `https://upload.ardrive.dev` | +| **Custom or self-hosted** | Your own base URL if it implements the selected uploader protocol | **Examples:** @@ -207,10 +207,40 @@ permaweb-deploy deploy --arns-name my-app --wallet ./wallet.json --uploader http permaweb-deploy upload --wallet ./wallet.json --deploy-folder ./dist --uploader https://up.arweave.net ``` +To upload through a HyperBEAM bundler, set `--uploader-type hyperbeam` and pass the node URL: + +```bash +permaweb-deploy upload \ + --wallet ./wallet.json \ + --deploy-folder ./dist \ + --uploader-type hyperbeam \ + --uploader https://hyperbeam.example.com + +permaweb-deploy deploy \ + --arns-name my-app \ + --wallet ./wallet.json \ + --deploy-folder ./dist \ + --uploader-type hyperbeam \ + --uploader https://hyperbeam.example.com +``` + +If the node follows the standard AO-paid HyperBEAM bundler flow, the CLI can fund the uploader wallet before uploading: + +```bash +permaweb-deploy upload \ + --wallet ./wallet.json \ + --deploy-folder ./dist \ + --uploader-type hyperbeam \ + --uploader https://hyperbeam.example.com \ + --hyperbeam-auto-fund +``` + **Notes:** -- Billing and signer behavior still follow Turbo; if an alternate bundler has different rules, check that provider’s docs. -- Use a **base URL only** (e.g. `https://up.arweave.net`), not a path to a specific file or route. +- Turbo billing and signer behavior follow Turbo. +- HyperBEAM uploads require an Arweave JWK signer. With `--hyperbeam-auto-fund`, the CLI signs each data item, asks the node's `metering@1.0` device for a byte quote, sends AO to the node address from `/~meta@1.0/info/address`, imports that deposit through `/~ao-payment@1.0/ingest`, and waits for the uploader's balance at `/ledger~node-process@1.0/now/balance/
` before uploading. The default route is `/~bundler@1.0/item?codec-device=ans104@1.0`; override it with `--hyperbeam-upload-path` if your node exposes a different bundler route. +- `--hyperbeam-fund-amount` is an optional override for the minimum local ledger balance to ensure, in AO base units. Without it, `--hyperbeam-auto-fund` uses the node's `metering@1.0` quote for the signed byte count. Use `--hyperbeam-token-id` only for a non-default AO token process, and `--hyperbeam-ledger-id` only for a non-default local ledger profile. +- Use a **base URL only** (e.g. `https://up.arweave.net` or `https://hyperbeam.example.com`), not a path to a specific file or route. ### Command Options @@ -229,9 +259,16 @@ permaweb-deploy upload --wallet ./wallet.json --deploy-folder ./dist --uploader - `--max-token-amount`: Maximum token amount for on-demand payment (used with `--on-demand`) - `--no-dedupe`: Disable deduplication (do not cache or reuse previous uploads) - `--dedupe-cache-max-entries`: Maximum number of entries to keep in the dedupe cache (LRU). Default: `10000` -- `--uploader`: Base URL of the Turbo **bundler service** to use (default: `https://upload.ardrive.io`). See [Bundler service](#bundler-service) above. +- `--uploader`: Base URL of the bundler service to use. See [Bundler service](#bundler-service) above. +- `--uploader-type`: Upload protocol to use (`turbo` or `hyperbeam`). Default: `turbo` +- `--hyperbeam-upload-path`: HyperBEAM bundler route. Default: `/~bundler@1.0/item?codec-device=ans104@1.0` +- `--hyperbeam-auto-fund`: Automatically fund the HyperBEAM local ledger before upload +- `--hyperbeam-fund-amount`: Optional minimum HyperBEAM local ledger balance override, in token base units +- `--hyperbeam-token-id`: Advanced AO token process ID override +- `--hyperbeam-ledger-id`: Advanced local HyperBEAM ledger ID override +- `--hyperbeam-ao-state-url`: AO state endpoint used while waiting for auto-fund transfer assignment. Default: `https://state.forward.computer` -**`upload`** (no ArNS): accepts `--deploy-folder`, `--deploy-file`, wallet/signer flags, `--uploader`, `--on-demand` / `--max-token-amount`, and dedupe flags only. +**`upload`** (no ArNS): accepts `--deploy-folder`, `--deploy-file`, wallet/signer flags, uploader flags, `--on-demand` / `--max-token-amount`, and dedupe flags only. ### Deduplication @@ -405,6 +442,20 @@ jobs: max-token-amount: '2.0' ``` +### With HyperBEAM + +```yaml +- name: Deploy through a HyperBEAM bundler + uses: permaweb/permaweb-deploy@v1 + with: + deploy-key: ${{ secrets.DEPLOY_KEY }} + arns-name: myapp + deploy-folder: ./dist + uploader-type: hyperbeam + uploader: https://hyperbeam.example.com + hyperbeam-auto-fund: 'true' +``` + ### Disabling Deduplication By default, the action caches transaction IDs to avoid re-uploading unchanged files. To disable this: diff --git a/action.yml b/action.yml index e4b03c0..3065e3e 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,34 @@ inputs: max-token-amount: description: 'Maximum token amount for on-demand payment (used with on-demand)' required: false + uploader: + description: 'Bundler service base URL. For HyperBEAM, pass the node URL, for example https://hyperbeam.example.com.' + required: false + uploader-type: + description: 'Uploader protocol to use: turbo or hyperbeam' + required: false + default: 'turbo' + hyperbeam-upload-path: + description: 'HyperBEAM bundler route used when uploader-type is hyperbeam' + required: false + default: '/~bundler@1.0/item?codec-device=ans104@1.0' + hyperbeam-auto-fund: + description: 'Automatically fund the HyperBEAM local ledger before upload' + required: false + default: 'false' + hyperbeam-fund-amount: + description: 'Optional minimum HyperBEAM local ledger balance override, in token base units' + required: false + hyperbeam-token-id: + description: 'Hyperbalance token ID to fund when the node advertises multiple tokens' + required: false + hyperbeam-ledger-id: + description: 'Hyperbalance ledger ID to fund when the node advertises multiple ledgers' + required: false + hyperbeam-ao-state-url: + description: 'AO state endpoint used while waiting for HyperBEAM auto-fund transfer assignment' + required: false + default: 'https://state.forward.computer' preview: description: 'Enable preview mode: auto-generates undername from PR number and posts a comment with the preview URL' required: false @@ -174,6 +202,30 @@ runs: fi fi + # Add uploader options + ARGS="$ARGS --uploader-type ${{ inputs.uploader-type }}" + if [[ -n "${{ inputs.uploader }}" ]]; then + ARGS="$ARGS --uploader ${{ inputs.uploader }}" + fi + if [[ "${{ inputs.uploader-type }}" == "hyperbeam" ]]; then + ARGS="$ARGS --hyperbeam-upload-path ${{ inputs.hyperbeam-upload-path }}" + if [[ "${{ inputs.hyperbeam-auto-fund }}" == "true" ]]; then + ARGS="$ARGS --hyperbeam-auto-fund" + fi + if [[ -n "${{ inputs.hyperbeam-fund-amount }}" ]]; then + ARGS="$ARGS --hyperbeam-fund-amount ${{ inputs.hyperbeam-fund-amount }}" + fi + if [[ -n "${{ inputs.hyperbeam-token-id }}" ]]; then + ARGS="$ARGS --hyperbeam-token-id ${{ inputs.hyperbeam-token-id }}" + fi + if [[ -n "${{ inputs.hyperbeam-ledger-id }}" ]]; then + ARGS="$ARGS --hyperbeam-ledger-id ${{ inputs.hyperbeam-ledger-id }}" + fi + if [[ -n "${{ inputs.hyperbeam-ao-state-url }}" ]]; then + ARGS="$ARGS --hyperbeam-ao-state-url ${{ inputs.hyperbeam-ao-state-url }}" + fi + fi + # Add dedupe options if [[ "${{ inputs.no-dedupe }}" == "true" ]]; then ARGS="$ARGS --no-dedupe" diff --git a/package.json b/package.json index e6232f7..3c3bb8d 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,14 @@ "dependencies": { "@ar.io/sdk": "^3.22.1", "@ardrive/turbo-sdk": "^1.39.1", + "@dha-team/arbundles": "^1.0.4", "@inquirer/prompts": "^7.2.0", "@oclif/core": "^4.0.30", "@permaweb/aoconnect": "^0.0.85", "boxen": "^8.0.1", "chalk": "^5.3.0", "cli-table3": "^0.6.5", + "hyperbalance": "github:xylophonez/hyperbalance#main", "mime-types": "^3.0.1", "ora": "^8.1.1", "p-limit": "^7.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a36ac36..6d1c6de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@ardrive/turbo-sdk': specifier: ^1.39.1 version: 1.41.0(@solana/sysvars@5.5.1(typescript@5.9.3))(@tanstack/query-core@5.95.2)(@tanstack/react-query@5.95.2(react@18.3.1))(bufferutil@4.1.0)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@dha-team/arbundles': + specifier: ^1.0.4 + version: 1.0.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@inquirer/prompts': specifier: ^7.2.0 version: 7.10.1(@types/node@22.19.15) @@ -32,6 +35,9 @@ importers: cli-table3: specifier: ^0.6.5 version: 0.6.5 + hyperbalance: + specifier: github:xylophonez/hyperbalance#main + version: https://codeload.github.com/xylophonez/hyperbalance/tar.gz/dcb009349368197c88586e4eba784bb5290203cb mime-types: specifier: ^3.0.1 version: 3.0.2 @@ -3922,6 +3928,11 @@ packages: resolution: {integrity: sha512-7wvJxzAEBREKotXGuHOSri8/J+D0oURqCbNmn8g7Ym8hVMJQkGCMMS9y2/GktMtRyxPVcw2xWFh2oPa970PmXQ==} engines: {node: '>=18'} + hyperbalance@https://codeload.github.com/xylophonez/hyperbalance/tar.gz/dcb009349368197c88586e4eba784bb5290203cb: + resolution: {tarball: https://codeload.github.com/xylophonez/hyperbalance/tar.gz/dcb009349368197c88586e4eba784bb5290203cb} + version: 0.1.0 + engines: {node: '>=18'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -7139,7 +7150,7 @@ snapshots: '@ethersproject/address': 5.8.0 '@ethersproject/base64': 5.8.0 '@ethersproject/bignumber': 5.8.0 - '@ethersproject/bytes': 5.7.0 + '@ethersproject/bytes': 5.8.0 '@ethersproject/keccak256': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/properties': 5.8.0 @@ -7216,9 +7227,9 @@ snapshots: '@ethersproject/base64': 5.8.0 '@ethersproject/basex': 5.8.0 '@ethersproject/bignumber': 5.8.0 - '@ethersproject/bytes': 5.7.0 + '@ethersproject/bytes': 5.8.0 '@ethersproject/constants': 5.8.0 - '@ethersproject/hash': 5.7.0 + '@ethersproject/hash': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/networks': 5.8.0 '@ethersproject/properties': 5.8.0 @@ -7226,7 +7237,7 @@ snapshots: '@ethersproject/rlp': 5.8.0 '@ethersproject/sha2': 5.8.0 '@ethersproject/strings': 5.8.0 - '@ethersproject/transactions': 5.7.0 + '@ethersproject/transactions': 5.8.0 '@ethersproject/web': 5.8.0 bech32: 1.1.4 ws: 7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -7278,7 +7289,7 @@ snapshots: '@ethersproject/signing-key@5.7.0': dependencies: - '@ethersproject/bytes': 5.7.0 + '@ethersproject/bytes': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/properties': 5.8.0 bn.js: 5.2.3 @@ -7304,13 +7315,13 @@ snapshots: dependencies: '@ethersproject/address': 5.8.0 '@ethersproject/bignumber': 5.8.0 - '@ethersproject/bytes': 5.7.0 + '@ethersproject/bytes': 5.8.0 '@ethersproject/constants': 5.8.0 '@ethersproject/keccak256': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/properties': 5.8.0 '@ethersproject/rlp': 5.8.0 - '@ethersproject/signing-key': 5.7.0 + '@ethersproject/signing-key': 5.8.0 '@ethersproject/transactions@5.8.0': dependencies: @@ -7330,16 +7341,16 @@ snapshots: '@ethersproject/abstract-signer': 5.8.0 '@ethersproject/address': 5.8.0 '@ethersproject/bignumber': 5.8.0 - '@ethersproject/bytes': 5.7.0 - '@ethersproject/hash': 5.7.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/hash': 5.8.0 '@ethersproject/hdnode': 5.8.0 '@ethersproject/json-wallets': 5.8.0 '@ethersproject/keccak256': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/properties': 5.8.0 '@ethersproject/random': 5.8.0 - '@ethersproject/signing-key': 5.7.0 - '@ethersproject/transactions': 5.7.0 + '@ethersproject/signing-key': 5.8.0 + '@ethersproject/transactions': 5.8.0 '@ethersproject/wordlists': 5.8.0 '@ethersproject/wallet@5.8.0': @@ -10142,7 +10153,7 @@ snapshots: arconnect@0.4.2: dependencies: - arweave: 1.15.5 + arweave: 1.15.7 argparse@1.0.10: dependencies: @@ -11640,6 +11651,8 @@ snapshots: hyper-async@1.2.0: {} + hyperbalance@https://codeload.github.com/xylophonez/hyperbalance/tar.gz/dcb009349368197c88586e4eba784bb5290203cb: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -13713,7 +13726,7 @@ snapshots: warp-arbundles@1.0.4: dependencies: - arweave: 1.15.5 + arweave: 1.15.7 base64url: 3.0.1 buffer: 6.0.3 warp-isomorphic: 1.0.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4de91a3..237b7d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - '.' +onlyBuiltDependencies: + - hyperbalance diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 0d5a2fc..0d9e224 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -14,6 +14,7 @@ import { promptAdvancedOptions } from '../prompts/arns.js' import { getWalletConfig } from '../prompts/wallet.js' import type { SignerType } from '../types/index.js' import { extractFlags, resolveConfig } from '../utils/config-resolver.js' +import { hyperbeamBundlerLink } from '../utils/hyperbeam-uploader.js' import { expandPath } from '../utils/path.js' import { createSigner } from '../utils/signer.js' import { runUploadWorkflow } from '../workflows/upload-workflow.js' @@ -33,6 +34,7 @@ export default class Deploy extends Command { '<%= config.bin %> deploy --arns-name my-app --sig-type ethereum --private-key "0x..."', '<%= config.bin %> deploy --arns-name my-app --on-demand ario --max-token-amount 1000', '<%= config.bin %> deploy --arns-name my-app --uploader https://up.arweave.net', + '<%= config.bin %> deploy --arns-name my-app --uploader-type hyperbeam --uploader https://hyperbeam.example.com', '<%= config.bin %> upload --wallet ./wallet.json # Upload only (no ArNS update)', ] @@ -90,6 +92,12 @@ export default class Deploy extends Command { 'dedupe-cache-max-entries': effectiveCacheMaxEntries, 'deploy-file': baseConfig['deploy-file'], 'deploy-folder': baseConfig['deploy-folder'], + 'hyperbeam-ao-state-url': baseConfig['hyperbeam-ao-state-url'], + 'hyperbeam-auto-fund': baseConfig['hyperbeam-auto-fund'], + 'hyperbeam-fund-amount': baseConfig['hyperbeam-fund-amount'], + 'hyperbeam-ledger-id': baseConfig['hyperbeam-ledger-id'], + 'hyperbeam-token-id': baseConfig['hyperbeam-token-id'], + 'hyperbeam-upload-path': baseConfig['hyperbeam-upload-path'], 'max-token-amount': advancedOptions?.maxTokenAmount || baseConfig['max-token-amount'], 'no-dedupe': baseConfig['no-dedupe'], 'on-demand': advancedOptions?.onDemand || baseConfig['on-demand'], @@ -98,6 +106,7 @@ export default class Deploy extends Command { 'ttl-seconds': advancedOptions?.ttlSeconds || baseConfig['ttl-seconds'], undername: advancedOptions?.undername || baseConfig.undername, uploader: baseConfig.uploader, + 'uploader-type': baseConfig['uploader-type'], wallet: walletConfig.wallet, } @@ -201,12 +210,21 @@ export default class Deploy extends Command { spinner.succeed('ANT record updated') const isCI = Boolean(process.env.CI) + const bundlerLink = + deployConfig['uploader-type'] === 'hyperbeam' && deployConfig.uploader + ? hyperbeamBundlerLink(deployConfig.uploader, txOrManifestId) + : undefined if (isCI) { this.log('Deployment Successful!') this.log('Tx ID: ' + txOrManifestId) if (deployConfig.uploader) { this.log('Bundler service: ' + deployConfig.uploader) + this.log('Uploader type: ' + deployConfig['uploader-type']) + } + + if (bundlerLink) { + this.log('Bundler link: ' + bundlerLink) } this.log('ArNS Name: ' + deployConfig['arns-name']) @@ -226,7 +244,13 @@ export default class Deploy extends Command { table.push( ['Tx ID', chalk.green(txOrManifestId)], ...(deployConfig.uploader - ? ([['Bundler service', chalk.cyan(deployConfig.uploader)]] as [string, string][]) + ? ([ + ['Bundler service', chalk.cyan(deployConfig.uploader)], + ['Uploader type', chalk.cyan(deployConfig['uploader-type'])], + ] as [string, string][]) + : []), + ...(bundlerLink + ? ([['Bundler link', chalk.yellow(bundlerLink)]] as [string, string][]) : []), ['ArNS Name', chalk.yellow(deployConfig['arns-name'])], ['Undername', chalk.yellow(deployConfig.undername)], diff --git a/src/commands/upload.ts b/src/commands/upload.ts index c1425da..c20589c 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -9,6 +9,7 @@ import Table from 'cli-table3' import { type UploadConfig, uploadFlagConfigs } from '../constants/flags.js' import { getWalletConfig } from '../prompts/wallet.js' import { extractFlags, resolveConfig } from '../utils/config-resolver.js' +import { hyperbeamBundlerLink } from '../utils/hyperbeam-uploader.js' import { expandPath } from '../utils/path.js' import { runUploadWorkflow } from '../workflows/upload-workflow.js' @@ -23,6 +24,7 @@ export default class Upload extends Command { '<%= config.bin %> upload --wallet ./wallet.json --deploy-file ./dist/index.html', '<%= config.bin %> upload --private-key "$(cat wallet.json)" --on-demand ario --max-token-amount 1.5', '<%= config.bin %> upload --wallet ./wallet.json --uploader https://up.arweave.net', + '<%= config.bin %> upload --wallet ./wallet.json --uploader-type hyperbeam --uploader https://hyperbeam.example.com', ] static override flags = extractFlags(uploadFlagConfigs) @@ -62,10 +64,17 @@ export default class Upload extends Command { 'dedupe-cache-max-entries': effectiveCacheMaxEntries, 'deploy-file': baseConfig['deploy-file'], 'deploy-folder': baseConfig['deploy-folder'], + 'hyperbeam-ao-state-url': baseConfig['hyperbeam-ao-state-url'], + 'hyperbeam-auto-fund': baseConfig['hyperbeam-auto-fund'], + 'hyperbeam-fund-amount': baseConfig['hyperbeam-fund-amount'], + 'hyperbeam-ledger-id': baseConfig['hyperbeam-ledger-id'], + 'hyperbeam-token-id': baseConfig['hyperbeam-token-id'], + 'hyperbeam-upload-path': baseConfig['hyperbeam-upload-path'], 'max-token-amount': baseConfig['max-token-amount'], 'on-demand': baseConfig['on-demand'], 'sig-type': baseConfig['sig-type'], uploader: baseConfig.uploader, + 'uploader-type': baseConfig['uploader-type'], } if (interactive) { @@ -109,12 +118,21 @@ export default class Upload extends Command { this.log('') const isCI = Boolean(process.env.CI) + const bundlerLink = + uploadCfg['uploader-type'] === 'hyperbeam' && uploadCfg.uploader + ? hyperbeamBundlerLink(uploadCfg.uploader, txOrManifestId) + : undefined if (isCI) { this.log('Upload successful!') this.log('Tx ID: ' + txOrManifestId) if (uploadCfg.uploader) { this.log('Bundler service: ' + uploadCfg.uploader) + this.log('Uploader type: ' + uploadCfg['uploader-type']) + } + + if (bundlerLink) { + this.log('Bundler link: ' + bundlerLink) } this.log(`Arweave URL: https://arweave.net/${txOrManifestId}`) @@ -127,7 +145,14 @@ export default class Upload extends Command { table.push(['Tx ID', chalk.green(txOrManifestId)]) if (uploadCfg.uploader) { - table.push(['Bundler service', chalk.cyan(uploadCfg.uploader)]) + table.push( + ['Bundler service', chalk.cyan(uploadCfg.uploader)], + ['Uploader type', chalk.cyan(uploadCfg['uploader-type'])], + ) + } + + if (bundlerLink) { + table.push(['Bundler link', chalk.yellow(bundlerLink)]) } table.push(['Arweave URL', chalk.yellow(`https://arweave.net/${txOrManifestId}`)]) diff --git a/src/constants/flags.ts b/src/constants/flags.ts index 221840d..7a5f3a1 100644 --- a/src/constants/flags.ts +++ b/src/constants/flags.ts @@ -94,6 +94,45 @@ export const globalFlags = { return target.type === 'folder' ? target.path : './dist' }, }), + hyperbeamAoStateUrl: createFlagConfig({ + flag: Flags.string({ + default: 'https://state.forward.computer', + description: 'AO state endpoint used to wait for HyperBEAM auto-fund transfer assignment.', + required: false, + }), + }), + hyperbeamAutoFund: createFlagConfig({ + flag: Flags.boolean({ + default: false, + description: 'Automatically fund the HyperBEAM local ledger before upload.', + required: false, + }), + }), + hyperbeamFundAmount: createFlagConfig({ + flag: Flags.string({ + description: 'Optional minimum HyperBEAM local ledger balance override, in token base units.', + required: false, + }), + }), + hyperbeamLedgerId: createFlagConfig({ + flag: Flags.string({ + description: 'Advanced: local HyperBEAM ledger ID to use for AO auto-funding.', + required: false, + }), + }), + hyperbeamTokenId: createFlagConfig({ + flag: Flags.string({ + description: 'Advanced: AO token process ID to use for HyperBEAM auto-funding.', + required: false, + }), + }), + hyperbeamUploadPath: createFlagConfig({ + flag: Flags.string({ + default: '/~bundler@1.0/item?codec-device=ans104@1.0', + description: 'HyperBEAM bundler route used when --uploader-type hyperbeam is set.', + required: false, + }), + }), // Advanced payment settings maxTokenAmount: createFlagConfig({ flag: Flags.string({ @@ -168,7 +207,16 @@ export const globalFlags = { uploader: createFlagConfig({ flag: Flags.string({ description: - 'Base URL of the Turbo bundler service to use (omit for ArDrive production: https://upload.ardrive.io). Examples: https://up.arweave.net (Arweave), https://upload.ardrive.dev (dev). The host must implement the Turbo bundler protocol; path/query are not required on the URL.', + 'Base URL of the bundler service to use. For Turbo, omit for ArDrive production: https://upload.ardrive.io. For HyperBEAM, pass the node URL, for example https://hyperbeam.example.com.', + required: false, + }), + }), + uploaderType: createFlagConfig({ + flag: Flags.string({ + default: 'turbo', + description: + 'Uploader protocol to use. turbo uses the Turbo bundler API; hyperbeam signs ANS-104 items and posts them to a HyperBEAM bundler route.', + options: ['turbo', 'hyperbeam'], required: false, }), }), @@ -199,6 +247,12 @@ export const deployFlags = { 'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries.flag, 'deploy-file': globalFlags.deployFile.flag, 'deploy-folder': globalFlags.deployFolder.flag, + 'hyperbeam-ao-state-url': globalFlags.hyperbeamAoStateUrl.flag, + 'hyperbeam-auto-fund': globalFlags.hyperbeamAutoFund.flag, + 'hyperbeam-fund-amount': globalFlags.hyperbeamFundAmount.flag, + 'hyperbeam-ledger-id': globalFlags.hyperbeamLedgerId.flag, + 'hyperbeam-token-id': globalFlags.hyperbeamTokenId.flag, + 'hyperbeam-upload-path': globalFlags.hyperbeamUploadPath.flag, 'max-token-amount': globalFlags.maxTokenAmount.flag, 'no-dedupe': globalFlags.noDedupe.flag, 'on-demand': globalFlags.onDemand.flag, @@ -207,6 +261,7 @@ export const deployFlags = { 'ttl-seconds': globalFlags.ttlSeconds.flag, undername: globalFlags.undername.flag, uploader: globalFlags.uploader.flag, + 'uploader-type': globalFlags.uploaderType.flag, wallet: globalFlags.wallet.flag, } @@ -238,6 +293,12 @@ export interface DeployConfig { 'dedupe-cache-max-entries': number 'deploy-file'?: string 'deploy-folder': string + 'hyperbeam-ao-state-url': string + 'hyperbeam-auto-fund': boolean + 'hyperbeam-fund-amount'?: string + 'hyperbeam-ledger-id'?: string + 'hyperbeam-token-id'?: string + 'hyperbeam-upload-path': string 'max-token-amount'?: string 'no-dedupe': boolean 'on-demand'?: string @@ -246,6 +307,7 @@ export interface DeployConfig { 'ttl-seconds': string undername: string uploader?: string + 'uploader-type': string wallet?: string } @@ -259,6 +321,12 @@ export const deployFlagConfigs = { 'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries, 'deploy-file': globalFlags.deployFile, 'deploy-folder': globalFlags.deployFolder, + 'hyperbeam-ao-state-url': globalFlags.hyperbeamAoStateUrl, + 'hyperbeam-auto-fund': globalFlags.hyperbeamAutoFund, + 'hyperbeam-fund-amount': globalFlags.hyperbeamFundAmount, + 'hyperbeam-ledger-id': globalFlags.hyperbeamLedgerId, + 'hyperbeam-token-id': globalFlags.hyperbeamTokenId, + 'hyperbeam-upload-path': globalFlags.hyperbeamUploadPath, 'max-token-amount': globalFlags.maxTokenAmount, 'no-dedupe': globalFlags.noDedupe, 'on-demand': globalFlags.onDemand, @@ -267,6 +335,7 @@ export const deployFlagConfigs = { 'ttl-seconds': globalFlags.ttlSeconds, undername: globalFlags.undername, uploader: globalFlags.uploader, + 'uploader-type': globalFlags.uploaderType, wallet: globalFlags.wallet, } as const @@ -277,12 +346,19 @@ export const uploadFlagConfigs = { 'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries, 'deploy-file': globalFlags.deployFile, 'deploy-folder': globalFlags.deployFolder, + 'hyperbeam-ao-state-url': globalFlags.hyperbeamAoStateUrl, + 'hyperbeam-auto-fund': globalFlags.hyperbeamAutoFund, + 'hyperbeam-fund-amount': globalFlags.hyperbeamFundAmount, + 'hyperbeam-ledger-id': globalFlags.hyperbeamLedgerId, + 'hyperbeam-token-id': globalFlags.hyperbeamTokenId, + 'hyperbeam-upload-path': globalFlags.hyperbeamUploadPath, 'max-token-amount': globalFlags.maxTokenAmount, 'no-dedupe': globalFlags.noDedupe, 'on-demand': globalFlags.onDemand, 'private-key': globalFlags.privateKey, 'sig-type': globalFlags.sigType, uploader: globalFlags.uploader, + 'uploader-type': globalFlags.uploaderType, wallet: globalFlags.wallet, } as const diff --git a/src/utils/__tests__/hyperbeam-uploader.test.ts b/src/utils/__tests__/hyperbeam-uploader.test.ts new file mode 100644 index 0000000..c31083b --- /dev/null +++ b/src/utils/__tests__/hyperbeam-uploader.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { + hyperbeamAoFundingHint, + hyperbeamBundlerLink, + parseHyperbeamFundAmount, +} from '../hyperbeam-uploader.js' + +describe('hyperbeamBundlerLink', () => { + it('builds a direct HyperBEAM raw resolver URL', () => { + expect(hyperbeamBundlerLink('https://hyperbeam.example.com', 'abc123')).toBe( + 'https://hyperbeam.example.com/~arweave@2.9/raw=abc123', + ) + }) + + it('handles uploader URLs with trailing slashes', () => { + expect(hyperbeamBundlerLink('https://hyperbeam.example.com/', 'abc123')).toBe( + 'https://hyperbeam.example.com/~arweave@2.9/raw=abc123', + ) + }) +}) + +describe('hyperbeamAoFundingHint', () => { + it('formats AO token and ledger payment metadata', () => { + expect( + hyperbeamAoFundingHint({ + ledgers: [ + { + balancePath: '/ledger~process@1.0/now/balance/{address}', + id: 'local-ao', + route: '/ledger~process@1.0', + type: 'process-ledger@1.0', + }, + ], + node: { operator: 'node-operator' }, + tokens: [ + { + id: 'ao-mainnet', + ledgerId: 'local-ao', + ticker: 'AO', + }, + ], + version: 'hyperbalance@0.1', + }), + ).toContain('AO (ao-mainnet): send funds to node-operator') + }) +}) + +describe('parseHyperbeamFundAmount', () => { + it('accepts positive token base-unit amounts', () => { + expect(parseHyperbeamFundAmount('1000000000000')).toBe(1_000_000_000_000n) + }) + + it('rejects zero, negative, decimal, and non-numeric amounts', () => { + for (const value of ['0', '-1', '1.5', 'AO']) { + expect(() => parseHyperbeamFundAmount(value)).toThrow(/positive integer/) + } + }) +}) diff --git a/src/utils/hyperbeam-uploader.ts b/src/utils/hyperbeam-uploader.ts new file mode 100644 index 0000000..fbc3d53 --- /dev/null +++ b/src/utils/hyperbeam-uploader.ts @@ -0,0 +1,348 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import { Readable } from 'node:stream' + +import { createDataItemSigner, message as aoMessage } from '@permaweb/aoconnect' +import { + AoTokenTransferAdapter, + DEFAULT_AO_TOKEN_ID, + discoverHyperbeamAoBundlerProfile, + type FundingResult, + HyperbalanceClient, + type HyperbalanceProfile, + waitForAoAssignmentSlot, +} from 'hyperbalance' + +const require = createRequire(import.meta.url) +const { ArweaveSigner, DataItem, createData } = require('@dha-team/arbundles') as { + ArweaveSigner: new (jwk: Record) => unknown + DataItem: new (raw: Buffer) => { id: string | Uint8Array } + createData: ( + data: Buffer, + signer: unknown, + opts?: { tags?: Array<{ name: string; value: string }> }, + ) => { + getRaw: () => Uint8Array + id?: string + sign: (signer: unknown) => Promise + } +} + +export interface UploadFileArgs { + dataItemOpts?: { tags?: Array<{ name: string; value: string }> } + file?: string | Buffer + fileSizeFactory?: () => number + fileStreamFactory?: () => unknown + fundingMode?: unknown +} + +export interface UploadClient { + uploadFile: (args: UploadFileArgs) => Promise<{ id?: string }> +} + +export interface HyperbeamBundlerOptions { + autoFund?: HyperbeamBundlerAutoFundOptions + deployKey: string + uploadPath: string + uploader: string +} + +export interface HyperbeamAutoFundOptions { + aoPollMs?: number + aoStateUrl?: string + aoTimeoutMs?: number + deployKey: string + ledgerId?: string + minimumBalance: bigint + tokenId?: string + uploader: string +} + +export interface HyperbeamBundlerAutoFundOptions { + aoPollMs?: number + aoStateUrl?: string + aoTimeoutMs?: number + deployKey: string + ledgerId?: string + minimumBalance?: bigint + quoteAction?: string + tokenId?: string + uploader: string +} + +async function readableToBuffer(stream: Readable): Promise { + const chunks: Buffer[] = [] + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks) +} + +async function streamToBuffer(stream: unknown): Promise { + if (Buffer.isBuffer(stream)) { + return stream + } + + if (stream instanceof Uint8Array) { + return Buffer.from(stream) + } + + if (stream instanceof Readable) { + return readableToBuffer(stream) + } + + if (stream && typeof (stream as { getReader?: unknown }).getReader === 'function') { + return readableToBuffer(Readable.fromWeb(stream as ReadableStream)) + } + + throw new Error('Unsupported upload stream type') +} + +function toBase64Url(value: string | Uint8Array): string { + if (typeof value === 'string') { + return value + } + + return Buffer.from(value).toString('base64url') +} + +function normalizeUploadUrl(base: string, uploadPath: string): string { + const normalizedBase = base.endsWith('/') ? base : `${base}/` + const cleanPath = uploadPath.startsWith('/') ? uploadPath.slice(1) : uploadPath + return new URL(cleanPath, normalizedBase).toString() +} + +function arweaveAddressFromJwk(jwk: Record): string { + if (typeof jwk.n !== 'string') { + throw new TypeError('Arweave JWK is missing modulus field "n"') + } + + return createHash('sha256').update(Buffer.from(jwk.n, 'base64url')).digest('base64url') +} + +export function parseHyperbeamFundAmount(value: string): bigint { + if (!/^[1-9]\d*$/.test(value)) { + throw new Error('--hyperbeam-fund-amount must be a positive integer in token base units') + } + + return BigInt(value) +} + +export async function autoFundHyperbeamLedger( + options: HyperbeamAutoFundOptions, +): Promise { + const profile = await discoverHyperbeamAoBundlerProfile({ + ledgerId: options.ledgerId, + nodeUrl: options.uploader, + tokenId: options.tokenId, + }) + + return ensureHyperbeamCredit(options, profile) +} + +async function ensureHyperbeamCredit( + options: HyperbeamAutoFundOptions, + profile: HyperbalanceProfile, +): Promise { + const jwk = JSON.parse(Buffer.from(options.deployKey, 'base64').toString('utf8')) as Record< + string, + unknown + > + const recipient = arweaveAddressFromJwk(jwk) + const signer = createDataItemSigner(jwk) + const client = new HyperbalanceClient({ nodeUrl: options.uploader }) + const adapter = new AoTokenTransferAdapter({ + async inferSender() { + return recipient + }, + async message(input) { + return aoMessage({ + data: input.data ?? '', + process: input.process, + signer, + tags: input.tags, + }) + }, + async waitForAssignmentSlot(messageId, context) { + return waitForAoAssignmentSlot({ + messageId, + pollMs: options.aoPollMs, + processId: context.processId, + stateUrl: options.aoStateUrl, + timeoutMs: options.aoTimeoutMs, + }) + }, + }) + + return client.ensureCreditAuto({ + ledgerId: options.ledgerId, + minimumBalance: options.minimumBalance, + profile, + recipient, + tokenId: options.tokenId ?? DEFAULT_AO_TOKEN_ID, + transferAdapter: adapter, + }) +} + +export async function autoFundQuotedHyperbeamLedger( + options: { signedBytes: number } & HyperbeamBundlerAutoFundOptions, +): Promise { + const profile = await discoverHyperbeamAoBundlerProfile({ + ledgerId: options.ledgerId, + nodeUrl: options.uploader, + tokenId: options.tokenId, + }) + const client = new HyperbalanceClient({ nodeUrl: options.uploader }) + let { ledgerId } = options + let { minimumBalance } = options + let { tokenId } = options + + if (minimumBalance === undefined) { + const quote = await client.quoteAuto({ + action: options.quoteAction ?? 'hyperbeam-upload', + params: { bytes: options.signedBytes }, + profile, + }) + minimumBalance = quote.amount + ledgerId ??= quote.ledgerId + tokenId ??= quote.tokenId + } + + return ensureHyperbeamCredit( + { + aoPollMs: options.aoPollMs, + aoStateUrl: options.aoStateUrl, + aoTimeoutMs: options.aoTimeoutMs, + deployKey: options.deployKey, + ledgerId, + minimumBalance, + tokenId, + uploader: options.uploader, + }, + profile, + ) +} + +export function hyperbeamBundlerLink(uploader: string, id: string): string { + const normalizedBase = uploader.endsWith('/') ? uploader : `${uploader}/` + return new URL(`~arweave@2.9/raw=${encodeURIComponent(id)}`, normalizedBase).toString() +} + +function responseId(headers: Headers, body: string): string | undefined { + const headerId = headers.get('id') + if (headerId) { + return headerId + } + + try { + const parsed = JSON.parse(body) as { body?: { id?: string }; id?: string } + return parsed.id || parsed.body?.id + } catch { + return undefined + } +} + +export class HyperbeamBundlerClient implements UploadClient { + private readonly autoFund?: HyperbeamBundlerAutoFundOptions + private readonly signer: unknown + private readonly uploader: string + private readonly uploadUrl: string + + constructor({ autoFund, deployKey, uploadPath, uploader }: HyperbeamBundlerOptions) { + const jwk = JSON.parse(Buffer.from(deployKey, 'base64').toString('utf8')) as Record< + string, + unknown + > + this.autoFund = autoFund + this.signer = new ArweaveSigner(jwk) + this.uploader = uploader + this.uploadUrl = normalizeUploadUrl(uploader, uploadPath) + } + + async uploadFile(args: UploadFileArgs): Promise<{ id: string }> { + const data = args.file + ? typeof args.file === 'string' + ? fs.readFileSync(args.file) + : args.file + : await streamToBuffer(args.fileStreamFactory?.() ?? Readable.from([])) + const tags = args.dataItemOpts?.tags ?? [] + const item = createData(data, this.signer, { tags }) + + await item.sign(this.signer) + + const raw = Buffer.from(item.getRaw()) + const localId = item.id || toBase64Url(new DataItem(raw).id) + + if (this.autoFund) { + await autoFundQuotedHyperbeamLedger({ + ...this.autoFund, + signedBytes: raw.length, + }) + } + + const res = await fetch(this.uploadUrl, { + body: raw, + headers: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/octet-stream', + }, + method: 'POST', + }) + const body = await res.text() + + if (!res.ok) { + const preview = body.replaceAll(/\s+/g, ' ').trim().slice(0, 300) + const paymentHint = res.status === 402 ? await this.paymentHint() : undefined + throw new Error( + [ + `HyperBEAM bundler upload failed with HTTP ${res.status}${preview ? `: ${preview}` : ''}`, + paymentHint, + ] + .filter(Boolean) + .join('\n\n'), + ) + } + + return { id: responseId(res.headers, body) || localId } + } + + private async paymentHint(): Promise { + try { + return hyperbeamAoFundingHint( + await discoverHyperbeamAoBundlerProfile({ nodeUrl: this.uploader }), + ) + } catch { + return undefined + } + } +} + +export function hyperbeamAoFundingHint(profile: HyperbalanceProfile): string | undefined { + const lines = profile.tokens + .map((token) => { + const depositAddress = token.depositAddress ?? profile.node?.operator + if (!depositAddress) return + + const label = token.ticker ? `${token.ticker} (${token.id})` : token.id + const ledger = token.ledgerId + ? profile.ledgers.find((candidate) => candidate.id === token.ledgerId) + : undefined + const ledgerInfo = ledger + ? ` Local ledger: ${ledger.id}${ledger.route ? ` at ${ledger.route}` : ''}.` + : '' + + return `- ${label}: send funds to ${depositAddress}.${ledgerInfo}` + }) + .filter(Boolean) + + if (lines.length === 0) return undefined + + return [ + 'The HyperBEAM node requires AO in its local ledger:', + ...lines, + 'Use --hyperbeam-auto-fund to transfer AO and import the credit automatically before upload.', + ].join('\n') +} diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index ad68ff3..83c20bb 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { Readable } from 'node:stream' -import { OnDemandFunding, type TurboAuthenticatedClient } from '@ardrive/turbo-sdk' +import { OnDemandFunding } from '@ardrive/turbo-sdk' import * as mime from 'mime-types' import pLimit from 'p-limit' @@ -13,6 +13,7 @@ import { touchCacheEntry, type TransactionCache, } from './cache.js' +import type { UploadClient } from './hyperbeam-uploader.js' export interface UploadResult { cacheHit: boolean @@ -30,7 +31,7 @@ export interface FolderUploadResult extends UploadResult { } export async function uploadFile( - turbo: TurboAuthenticatedClient, + turbo: UploadClient, filePath: string, options?: { cache?: TransactionCache @@ -111,9 +112,14 @@ interface FileUploadTask { * Upload a folder with per-file deduplication. * Each file is checked against the cache individually, and only uncached files are uploaded. * A manifest is then constructed and uploaded to create the folder structure. + * + * @param turbo - Upload client used for file and manifest uploads. + * @param folderPath - Folder to upload. + * @param options - Upload options for caching, concurrency, funding, and failure handling. + * @returns Folder upload result including manifest transaction ID and cache stats. */ export async function uploadFolder( - turbo: TurboAuthenticatedClient, + turbo: UploadClient, folderPath: string, options?: { cache?: TransactionCache diff --git a/src/workflows/upload-workflow.ts b/src/workflows/upload-workflow.ts index ecec381..30b0fdd 100644 --- a/src/workflows/upload-workflow.ts +++ b/src/workflows/upload-workflow.ts @@ -13,6 +13,12 @@ import ora from 'ora' import type { SignerType } from '../types/index.js' import { cleanupCache, loadCache, saveCache } from '../utils/cache.js' +import { + type HyperbeamBundlerAutoFundOptions, + HyperbeamBundlerClient, + parseHyperbeamFundAmount, + type UploadClient, +} from '../utils/hyperbeam-uploader.js' import { expandPath } from '../utils/path.js' import { createSigner } from '../utils/signer.js' import { type FolderUploadResult, uploadFile, uploadFolder } from '../utils/uploader.js' @@ -21,10 +27,17 @@ export interface UploadWorkflowConfig { 'dedupe-cache-max-entries': number 'deploy-file'?: string 'deploy-folder': string + 'hyperbeam-ao-state-url'?: string + 'hyperbeam-auto-fund'?: boolean + 'hyperbeam-fund-amount'?: string + 'hyperbeam-ledger-id'?: string + 'hyperbeam-token-id'?: string + 'hyperbeam-upload-path'?: string 'max-token-amount'?: string 'on-demand'?: string 'sig-type': string uploader?: string + 'uploader-type'?: string } function getFolderSize(folderPath: string): number { @@ -59,21 +72,63 @@ export async function runUploadWorkflow( ): Promise { const spinner = ora() - spinner.start('Creating signer') - const { signer, token } = createSigner(config['sig-type'] as SignerType, deployKey) - spinner.succeed(`Signer created (${chalk.cyan(config['sig-type'])})`) + const uploaderType = config['uploader-type'] ?? 'turbo' + let uploadClient: UploadClient + let turbo: ReturnType | undefined - spinner.start('Initializing Turbo') + if (uploaderType === 'hyperbeam') { + if (config['sig-type'] !== 'arweave') { + io.error('HyperBEAM uploads require --sig-type arweave') + } - const turboFactoryArgs: TurboAuthenticatedConfiguration = { signer, token } + if (!config.uploader) { + io.error('HyperBEAM uploads require --uploader ') + } - if (config.uploader) { - turboFactoryArgs.uploadServiceConfig = { url: config.uploader } - } + if (config['on-demand']) { + io.error('HyperBEAM uploads do not support Turbo --on-demand payments') + } + + let autoFund: HyperbeamBundlerAutoFundOptions | undefined + if (config['hyperbeam-auto-fund']) { + autoFund = { + deployKey, + uploader: config.uploader, + } + if (config['hyperbeam-ao-state-url']) autoFund.aoStateUrl = config['hyperbeam-ao-state-url'] + if (config['hyperbeam-ledger-id']) autoFund.ledgerId = config['hyperbeam-ledger-id'] + if (config['hyperbeam-token-id']) autoFund.tokenId = config['hyperbeam-token-id'] + if (config['hyperbeam-fund-amount']) { + autoFund.minimumBalance = parseHyperbeamFundAmount(config['hyperbeam-fund-amount']) + } + } + + spinner.start('Initializing HyperBEAM bundler') + uploadClient = new HyperbeamBundlerClient({ + autoFund, + deployKey, + uploadPath: config['hyperbeam-upload-path'] ?? '/~bundler@1.0/item?codec-device=ans104@1.0', + uploader: config.uploader, + }) + spinner.succeed(`HyperBEAM bundler initialized (${chalk.cyan(config.uploader)})`) + } else { + spinner.start('Creating signer') + const { signer, token } = createSigner(config['sig-type'] as SignerType, deployKey) + spinner.succeed(`Signer created (${chalk.cyan(config['sig-type'])})`) + + spinner.start('Initializing Turbo') - const turbo = TurboFactory.authenticated(turboFactoryArgs) + const turboFactoryArgs: TurboAuthenticatedConfiguration = { signer, token } - spinner.succeed('Turbo initialized') + if (config.uploader) { + turboFactoryArgs.uploadServiceConfig = { url: config.uploader } + } + + turbo = TurboFactory.authenticated(turboFactoryArgs) + uploadClient = turbo as UploadClient + + spinner.succeed('Turbo initialized') + } let fundingMode: OnDemandFunding | undefined if (config['on-demand'] && config['max-token-amount']) { @@ -103,7 +158,7 @@ export async function runUploadWorkflow( }) } - if (!fundingMode) { + if (!fundingMode && turbo) { spinner.start('Checking Turbo credits for upload') try { @@ -156,7 +211,7 @@ export async function runUploadWorkflow( spinner.start(`Uploading file ${chalk.yellow(config['deploy-file'])}`) let cache = config['dedupe-cache-max-entries'] > 0 ? loadCache() : {} - const uploadResult = await uploadFile(turbo, filePath, { cache, fundingMode }) + const uploadResult = await uploadFile(uploadClient, filePath, { cache, fundingMode }) if (!uploadResult.transactionId) { spinner.fail('File upload failed: no transaction ID returned') @@ -182,8 +237,9 @@ export async function runUploadWorkflow( spinner.start(`Uploading folder ${chalk.yellow(config['deploy-folder'])}`) let cache = config['dedupe-cache-max-entries'] > 0 ? loadCache() : {} - const uploadResult: FolderUploadResult = await uploadFolder(turbo, folderPath, { + const uploadResult: FolderUploadResult = await uploadFolder(uploadClient, folderPath, { cache, + concurrency: config['hyperbeam-auto-fund'] ? 1 : undefined, fundingMode, throwOnFailure: true, }) diff --git a/tests/e2e/deploy-command.test.ts b/tests/e2e/deploy-command.test.ts index b84b035..4da5285 100644 --- a/tests/e2e/deploy-command.test.ts +++ b/tests/e2e/deploy-command.test.ts @@ -1,4 +1,5 @@ import { runCommand } from '@oclif/test' +import { http, HttpResponse } from 'msw' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { TEST_ETH_PRIVATE_KEY } from '../constants.js' @@ -142,6 +143,88 @@ describe( expect(result.error).toBeUndefined() }) + describe('hyperbeam uploader', () => { + it('should upload a file through a HyperBEAM bundler route', async () => { + const seenUploads: Array<{ contentType: string; size: number }> = [] + + server.use( + http.post('https://hyperbeam.test/~bundler@1.0/item', async ({ request }) => { + const raw = Buffer.from(await request.arrayBuffer()) + seenUploads.push({ + contentType: request.headers.get('content-type') || '', + size: raw.length, + }) + + return new HttpResponse('HyperBEAM', { + headers: { id: 'mock-hyperbeam-dataitem-id' }, + status: 200, + }) + }), + ) + + const result = await runCommand([ + 'upload', + '--deploy-file', + './tests/fixtures/test-app/index.html', + '--wallet', + './tests/fixtures/test_wallet.json', + '--uploader-type', + 'hyperbeam', + '--uploader', + 'https://hyperbeam.test', + '--no-dedupe', + ]) + + expect(result.error).toBeUndefined() + expect(seenUploads).toHaveLength(1) + expect(seenUploads[0].contentType).toBe('application/octet-stream') + expect(seenUploads[0].size).toBeGreaterThan(0) + }) + + it('should require an uploader URL for HyperBEAM uploads', async () => { + const { error } = await runCommand([ + 'upload', + '--deploy-file', + './tests/fixtures/test-app/index.html', + '--wallet', + './tests/fixtures/test_wallet.json', + '--uploader-type', + 'hyperbeam', + ]) + + expect(error).toBeDefined() + expect(error?.message).toMatch(/require --uploader/) + }) + + it('should include AO funding metadata when a HyperBEAM upload needs payment', async () => { + server.use( + http.get('https://hyperbeam.test/~meta@1.0/info/address', () => + HttpResponse.text('node-deposit-address'), + ), + http.post('https://hyperbeam.test/~bundler@1.0/item', () => + HttpResponse.text('insufficient local ledger balance', { status: 402 }), + ), + ) + + const result = await runCommand([ + 'upload', + '--deploy-file', + './tests/fixtures/test-app/index.html', + '--wallet', + './tests/fixtures/test_wallet.json', + '--uploader-type', + 'hyperbeam', + '--uploader', + 'https://hyperbeam.test', + '--no-dedupe', + ]) + + expect(result.error).toBeDefined() + expect(result.error?.message).toContain('node-deposit-address') + expect(result.error?.message).toContain('default') + }) + }) + describe('arweave signer', () => { describe('upload folder', () => { it('should deploy without payment', async () => {