From 055dd322d8d36d29ed1120a752d37aa5f38ae334 Mon Sep 17 00:00:00 2001 From: xylophonez Date: Thu, 18 Jun 2026 19:53:25 +0100 Subject: [PATCH] feat: handle hyperbalance free-tier advisories --- package.json | 2 +- pnpm-lock.yaml | 31 ++++++++++-- .../__tests__/hyperbeam-uploader.test.ts | 43 ++++++++++++++++ src/utils/hyperbeam-uploader.ts | 49 +++++++++++++++++-- 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 61a6246..bedf72e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@inquirer/prompts": "^7.2.0", "@oclif/core": "^4.0.30", "@permaweb/aoconnect": "^0.0.85", - "@permaweb/hyperbalance": "^0.1.0", + "@permaweb/hyperbalance": "^0.2.0", "@permaweb/references": "^0.1.0", "arweave": "^1.15.7", "mime-types": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfda5b3..9046685 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^0.0.85 version: 0.0.85(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@permaweb/hyperbalance': - specifier: ^0.1.0 - version: 0.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + specifier: ^0.2.0 + version: 0.2.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@permaweb/references': specifier: ^0.1.0 version: 0.1.0(arbundles@0.11.2(arweave@1.15.7)(bufferutil@4.1.0)(utf-8-validate@5.0.10))(arweave@1.15.7) @@ -1120,8 +1120,8 @@ packages: resolution: {integrity: sha512-0Xfjlt3T4J6SV6YFu9tMyM5fVq/yImjRZGVsZWGKBbboZU8SZ5HlgC+JenjJs9u/MaU1pecyhbofa7sITdebwQ==} engines: {node: '>=18'} - '@permaweb/hyperbalance@0.1.0': - resolution: {integrity: sha512-lg2s4RpEk5UTEzjx4ErIclMZ9zIlAmkesv/PZHxO7xZ/083uQ3wfMaQCi40LbYONlJrzIapA12s6uA74YM6VqQ==} + '@permaweb/hyperbalance@0.2.0': + resolution: {integrity: sha512-U18AFF4Y6n4TFe+fqkATF3XIBmlrgjCNtjS627tlRSsufts9iOdUe2W9ttmvdihUAGIUTpmxBtS4lGIlLCqXNA==} engines: {node: '>=18'} '@permaweb/protocol-tag-utils@0.0.2': @@ -1208,66 +1208,79 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -1554,41 +1567,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -5658,7 +5679,7 @@ snapshots: - supports-color - utf-8-validate - '@permaweb/hyperbalance@0.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + '@permaweb/hyperbalance@0.2.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)': dependencies: '@permaweb/aoconnect': 0.0.96(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: diff --git a/src/utils/__tests__/hyperbeam-uploader.test.ts b/src/utils/__tests__/hyperbeam-uploader.test.ts index 25da0d8..1092897 100644 --- a/src/utils/__tests__/hyperbeam-uploader.test.ts +++ b/src/utils/__tests__/hyperbeam-uploader.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest' import { hyperbeamAoFundingHint, hyperbeamBundlerLink, + hyperbeamFreeTierExhaustedMessage, + isConditionalFreeTierQuote, parseHyperbeamFundAmount, } from '../hyperbeam-uploader.js' @@ -63,3 +65,44 @@ describe('parseHyperbeamFundAmount', () => { } }) }) + +describe('isConditionalFreeTierQuote', () => { + it('detects zero quotes that are conditional on free-tier quota', () => { + expect( + isConditionalFreeTierQuote({ + advisories: [ + { + code: 'conditional-free-tier', + message: 'Quota-dependent free quote', + severity: 'warning', + }, + ], + amount: 0n, + }), + ).toBe(true) + }) + + it('does not treat paid quotes as conditional free-tier quotes', () => { + expect( + isConditionalFreeTierQuote({ + advisories: [ + { + code: 'conditional-free-tier', + message: 'Quota-dependent free quote', + severity: 'warning', + }, + ], + amount: 1n, + }), + ).toBe(false) + }) +}) + +describe('hyperbeamFreeTierExhaustedMessage', () => { + it('explains that trundler exhaustion becomes paid fallback', () => { + expect(hyperbeamFreeTierExhaustedMessage('Insufficient funds')).toContain( + 'free-tier quota was exhausted', + ) + expect(hyperbeamFreeTierExhaustedMessage()).toContain('AO payment') + }) +}) diff --git a/src/utils/hyperbeam-uploader.ts b/src/utils/hyperbeam-uploader.ts index 3b623c2..13c3e36 100644 --- a/src/utils/hyperbeam-uploader.ts +++ b/src/utils/hyperbeam-uploader.ts @@ -13,6 +13,7 @@ import { type HyperbalanceProfile, HYPERBEAM_DEFAULT_LEDGER_ID, HYPERBEAM_DEFAULT_LEDGER_ROUTE, + type Quote, waitForAoAssignmentSlot, } from '@permaweb/hyperbalance' @@ -290,7 +291,7 @@ export async function autoFundQuotedHyperbeamLedger( export async function quoteHyperbeamUpload( options: { signedBytes: number } & HyperbeamBundlerQuoteOptions, -): Promise<{ amount: bigint; ledgerId?: string; tokenId?: string }> { +): Promise<{ amount: bigint; conditionalFreeTier?: boolean; ledgerId?: string; tokenId?: string }> { const profile = await discoverHyperbeamAoBundlerProfile({ ledgerId: options.ledgerId, nodeUrl: options.uploader, @@ -302,7 +303,12 @@ export async function quoteHyperbeamUpload( profile, }) - return { amount: quote.amount, ledgerId: quote.ledgerId, tokenId: quote.tokenId } + return { + amount: quote.amount, + conditionalFreeTier: isConditionalFreeTierQuote(quote), + ledgerId: quote.ledgerId, + tokenId: quote.tokenId, + } } export function hyperbeamBundlerLink(uploader: string, id: string, isManifest = false): string { @@ -411,6 +417,30 @@ function autoFundUnavailableMessage(message: string): string { ].join('\n') } +export function isConditionalFreeTierQuote(quote: Pick): boolean { + return ( + quote.amount === 0n && + (quote.advisories?.some((advisory) => advisory.code === 'conditional-free-tier') ?? false) + ) +} + +export function hyperbeamFreeTierExhaustedMessage(preview?: string): string { + const suffix = preview ? `: ${preview}` : '' + return [ + `HyperBEAM bundler free-tier quota was exhausted during upload settlement${suffix}`, + 'The node is now requiring AO payment. Direct quote calls do not reserve free-tier quota, so retry after funding the local ledger.', + ].join('\n\n') +} + +function uploadErrorPreview(message: string, status: number): string | undefined { + const marker = `HTTP ${status}` + const start = message.indexOf(marker) + if (start < 0) return undefined + + const suffix = message.slice(start + marker.length).trim() + return suffix.startsWith(':') ? suffix.slice(1).trim() || undefined : undefined +} + export class HyperbeamBundlerClient implements UploadClient { private readonly autoFund?: HyperbeamBundlerAutoFundOptions private readonly quote: HyperbeamBundlerQuoteOptions @@ -446,13 +476,17 @@ export class HyperbeamBundlerClient implements UploadClient { const localId = item.id || toBase64Url(new DataItem(raw).id) const size: UploadSize = { payloadBytes: data.length, signedBytes: raw.length } let autoFundUnavailable: string | undefined - let autoFundQuote: { amount: bigint; ledgerId?: string; tokenId?: string } | undefined + let autoFundQuote: + | { amount: bigint; conditionalFreeTier?: boolean; ledgerId?: string; tokenId?: string } + | undefined let cost: UploadCost | undefined + let conditionalFreeTierQuote = false if (this.autoFund) { try { autoFundQuote = await quoteHyperbeamUpload({ ...this.quote, signedBytes: raw.length }) cost = { amount: autoFundQuote.amount, token: 'AO' } + conditionalFreeTierQuote = autoFundQuote.conditionalFreeTier ?? false } catch (error) { autoFundUnavailable = cleanAutoFundErrorMessage( error instanceof Error ? error.message : String(error), @@ -462,6 +496,7 @@ export class HyperbeamBundlerClient implements UploadClient { try { const quote = await quoteHyperbeamUpload({ ...this.quote, signedBytes: raw.length }) cost = { amount: quote.amount, token: 'AO' } + conditionalFreeTierQuote = quote.conditionalFreeTier ?? false } catch { cost = undefined } @@ -503,11 +538,15 @@ export class HyperbeamBundlerClient implements UploadClient { const message = error instanceof Error ? error.message : String(error) const needsPayment = message.includes('HTTP 402') const paymentHint = needsPayment - ? await this.paymentHint(autoFundUnavailable ? false : undefined) + ? await this.paymentHint(this.autoFund || autoFundUnavailable ? false : undefined) : undefined + const uploadMessage = + needsPayment && conditionalFreeTierQuote + ? hyperbeamFreeTierExhaustedMessage(uploadErrorPreview(message, 402)) + : message throw new Error( [ - message, + uploadMessage, needsPayment && autoFundUnavailable ? autoFundUnavailableMessage(autoFundUnavailable) : undefined,