diff --git a/README.md b/README.md index 3a73753..9569c15 100644 --- a/README.md +++ b/README.md @@ -76,28 +76,45 @@ console.log(quote.advisories) LapEE-style HyperBEAM bundlers can expose a free byte tier and use `trundler@1.0` to limit how often a signer or IP receives that free tier. -`discoverHyperbeamAoBundlerProfile` keeps the quote route on -`/~arweave-byte-pricing@1.0/quote`, and annotates the pricing descriptor with: +`discoverHyperbeamAoBundlerProfile` keeps the amount-only quote route on +`/~arweave-byte-pricing@1.0/quote`, adds exact-request preflight at +`/~arweave-byte-pricing@1.0/preflight`, and annotates the pricing descriptor with: - `subject`: the quoted input is an `arweave-bytes` byte count supplied as `{bytes}`; - `settlement`: the real upload is settled by P4 on `/~bundler@1.0/item` and `/~bundler@1.0/tx`, with insufficient balance surfaced as HTTP 402; - `zeroQuote`: a zero quote can be a conditional free-tier result backed by - `trundler@1.0`, and direct quote calls do not consume or reserve quota. + `trundler@1.0`; amount-only quote calls do not consume quota, while preflight + consumes/reserves quota for the exact signed request. -When a zero quote is conditional, `client.quote` returns a warning advisory: +Amount-only quotes are conservative on trundler-aware nodes. If the node cannot +prove that the exact request owns the next free slot, it returns the paid fallback +amount so clients can fund the upload safely. + +For deterministic UX, call `client.preflight` after constructing the signed +bundler upload request and before submitting it: ```ts -if (quote.advisories?.some((item) => item.code === "conditional-free-tier")) { - // The upload may still return HTTP 402 if server-side free-tier quota is gone. +const preflight = await client.preflight({ + action: HYPERBEAM_AO_BUNDLER_QUOTE_ACTION, + profile, + params: { bytes: file.size }, + request: signedUploadRequest, +}) + +if (preflight.decision === "free") { + // The node reserved free-tier eligibility for this exact signed request. +} + +if (preflight.paymentRequired) { + // Fund at least preflight.amount before uploading. } ``` The bundler behavior is paid fallback, not hard block: when trundler quota is -exhausted, the upload should be priced normally through P4. Client tools should -therefore treat conditional zero quotes as "try without funding, but be ready to -fund/retry on HTTP 402" rather than as an unconditional free guarantee. +exhausted, preflight returns `decision: "paid"` with the amount required for AO +settlement. ## Funding diff --git a/src/client.ts b/src/client.ts index c299211..e620de9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,6 +18,10 @@ import type { PaidRequest, PaidRequestQuote, PaidRequestResult, + PreflightAutoRequest, + PreflightDecision, + PreflightQuote, + PreflightRequest, PricingDescriptor, Quote, QuoteAutoRequest, @@ -195,6 +199,46 @@ export class HyperbalanceClient { return this.quote(quoteRequest) } + async preflight(request: PreflightRequest): Promise { + const descriptor = request.profile.pricing?.find( + (candidate) => candidate.action === request.action, + ) + const preflightPath = descriptor?.preflightPath ?? preflightPathFromQuotePath(descriptor) + if (!descriptor || !preflightPath) { + throw new Error(`No preflight path advertised for action: ${request.action}`) + } + + const values = request.params ?? {} + const body = { + ...applyTemplateMap(descriptor.query, values), + request: request.request, + } + const response = await this.fetch(this.absoluteUrl(preflightPath), { + body: JSON.stringify(body, hyperbeamJsonReplacer), + headers: { + accept: "application/json", + "content-type": "application/json", + }, + method: "POST", + }) + + if (!response.ok) { + throw new Error(`Preflight request failed: ${response.status} ${response.statusText}`) + } + + return annotatePreflight(await parsePreflightQuoteResponse(response), descriptor) + } + + async preflightAuto(request: PreflightAutoRequest): Promise { + const preflightRequest: PreflightRequest = { + action: request.action, + profile: request.profile ?? (await this.discover()), + request: request.request, + } + if (request.params !== undefined) preflightRequest.params = request.params + return this.preflight(preflightRequest) + } + async paidRequest(request: PaidRequest): Promise { const profile = request.profile ?? (await this.discover()) const targetOptions: { ledgerId?: string; tokenId?: string; transferKind?: string } = {} @@ -365,6 +409,123 @@ function annotateQuote(quote: Quote, descriptor: PricingDescriptor): Quote { } } +async function parsePreflightQuoteResponse(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? "" + const rawText = await response.text() + const raw = parseMaybeJson(rawText, contentType) + const amount = parseRequiredBigint( + readField(raw, "amount") ?? response.headers.get("amount") ?? rawText.trim(), + ) + const ledgerId = parseOptionalString(readField(raw, "ledgerId") ?? response.headers.get("ledgerId")) + const tokenId = parseOptionalString(readField(raw, "tokenId") ?? response.headers.get("tokenId")) + const decision = parseDecision(readField(raw, "decision") ?? response.headers.get("decision")) + const paymentRequired = + parseBoolean(readField(raw, "payment-required") ?? response.headers.get("payment-required")) ?? + amount > 0n + const bytes = parseOptionalBigint(readField(raw, "bytes") ?? response.headers.get("bytes")) + const exhaustedBehavior = parseExhaustedBehavior( + readField(raw, "exhausted-behavior") ?? response.headers.get("exhausted-behavior"), + ) + + return { + amount, + ...(bytes !== undefined && { bytes }), + decision: decision ?? (amount === 0n ? "free" : "paid"), + ...(exhaustedBehavior !== undefined && { exhaustedBehavior }), + ...(ledgerId !== undefined && { ledgerId }), + paymentRequired, + raw: raw ?? rawText, + ...(tokenId !== undefined && { tokenId }), + } +} + +function annotatePreflight( + quote: PreflightQuote, + descriptor: PricingDescriptor, +): PreflightQuote { + if (quote.decision !== "free" || descriptor.zeroQuote?.kind !== "conditional-free-tier") { + return quote + } + + return { + ...quote, + advisories: [ + ...(quote.advisories ?? []), + { + code: "conditional-free-tier", + message: + "Free-tier eligibility was reserved for this exact request; retry with the same signed request or preflight again.", + severity: "info", + }, + ], + } +} + +function preflightPathFromQuotePath(descriptor: PricingDescriptor | undefined): string | undefined { + if (!descriptor?.quotePath?.endsWith("/quote")) return undefined + return `${descriptor.quotePath.slice(0, -"/quote".length)}/preflight` +} + +function hyperbeamJsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === "bigint") return value.toString() + if (value instanceof Uint8Array) return Array.from(value) + if (value instanceof ArrayBuffer) return Array.from(new Uint8Array(value)) + return value +} + +function readField(raw: unknown, key: string): unknown { + if (!raw || typeof raw !== "object") return undefined + return (raw as Record)[key] +} + +function parseDecision(value: unknown): PreflightDecision | undefined { + if (typeof value !== "string") return undefined + if (value === "free" || value === "paid" || value === "blocked" || value === "unknown") { + return value + } + return undefined +} + +function parseBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value + if (typeof value === "string") { + if (value === "true") return true + if (value === "false") return false + } + return undefined +} + +function parseOptionalBigint(value: unknown): bigint | undefined { + if (typeof value === "bigint") return value + if (typeof value === "number") return BigInt(value) + if (typeof value === "string" && value.trim()) return BigInt(value) + return undefined +} + +function parseRequiredBigint(value: unknown): bigint { + const parsed = parseOptionalBigint(value) + if (parsed === undefined) throw new Error("Could not parse preflight amount response") + return parsed +} + +function parseOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} + +function parseMaybeJson(raw: string, contentType: string): unknown { + if (!contentType.includes("application/json")) return undefined + try { + return JSON.parse(raw) as unknown + } catch { + return undefined + } +} + +function parseExhaustedBehavior(value: unknown): "charged" | "blocked" | undefined { + if (value === "charged" || value === "blocked") return value + return undefined +} + function maxBigint(left: bigint, right: bigint): bigint { return left > right ? left : right } diff --git a/src/conventions.ts b/src/conventions.ts index 5529274..cf06e49 100644 --- a/src/conventions.ts +++ b/src/conventions.ts @@ -67,11 +67,12 @@ export async function discoverHyperbeamAoBundlerProfile( resource: "arweave-bytes", }, quotePath: "/~arweave-byte-pricing@1.0/quote", + preflightPath: "/~arweave-byte-pricing@1.0/preflight", quoteSemantics: { authority: "advisory", notes: [ - "Zero quotes may be conditional on the node's bundler free-tier quota.", - "The quote endpoint does not reserve trundler quota; P4 prices the signed upload request.", + "Amount-only quotes are a guaranteed paid fallback when free-tier quota is conditional.", + "Use the preflight path with the exact signed bundler request to reserve and display free-tier eligibility before upload.", ], }, settlement: { @@ -96,6 +97,7 @@ export async function discoverHyperbeamAoBundlerProfile( policyId: HYPERBEAM_BUNDLER_FREE_TIER_POLICY_ID, }, quoteConsumesQuota: false, + preflightConsumesQuota: true, resource: "arweave-bytes", }, }, diff --git a/src/index.ts b/src/index.ts index 4f20f4d..def103a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,10 @@ export type { PaidRequestResult, PaymentImportDescriptor, PaymentNodeDescriptor, + PreflightAutoRequest, + PreflightDecision, + PreflightQuote, + PreflightRequest, PricedAction, PricingDescriptor, PricingQuotaDescriptor, diff --git a/src/types.ts b/src/types.ts index 878f793..e956dbc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,7 @@ export interface PricingDescriptor { action: string method?: "GET" | "POST" body?: Record + preflightPath?: string query?: Record quotePath?: string quoteSemantics?: PricingQuoteSemantics @@ -113,6 +114,7 @@ export interface PricingZeroQuoteDescriptor { exhaustedBehavior: "charged" | "blocked" kind: "conditional-free-tier" | "unconditional-free" limitParam?: string + preflightConsumesQuota?: boolean quota?: PricingQuotaDescriptor quoteConsumesQuota?: boolean resource?: string @@ -138,6 +140,15 @@ export interface Quote { tokenId?: string } +export type PreflightDecision = "free" | "paid" | "blocked" | "unknown" + +export interface PreflightQuote extends Quote { + bytes?: bigint + decision: PreflightDecision + exhaustedBehavior?: "charged" | "blocked" + paymentRequired: boolean +} + export interface QuoteAdvisory { code: "conditional-free-tier" message: string @@ -150,12 +161,26 @@ export interface QuoteRequest { profile: HyperbalanceProfile } +export interface PreflightRequest { + action: string + params?: Record + profile: HyperbalanceProfile + request: HyperbeamSignedRequestFields +} + export interface QuoteAutoRequest { action: string params?: Record profile?: HyperbalanceProfile } +export interface PreflightAutoRequest { + action: string + params?: Record + profile?: HyperbalanceProfile + request: HyperbeamSignedRequestFields +} + export interface PaidRequestQuote { action: string params?: Record