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 () => {