Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
161 changes: 161 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import type {
PaidRequest,
PaidRequestQuote,
PaidRequestResult,
PreflightAutoRequest,
PreflightDecision,
PreflightQuote,
PreflightRequest,
PricingDescriptor,
Quote,
QuoteAutoRequest,
Expand Down Expand Up @@ -195,6 +199,46 @@ export class HyperbalanceClient {
return this.quote(quoteRequest)
}

async preflight(request: PreflightRequest): Promise<PreflightQuote> {
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<PreflightQuote> {
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<PaidRequestResult> {
const profile = request.profile ?? (await this.discover())
const targetOptions: { ledgerId?: string; tokenId?: string; transferKind?: string } = {}
Expand Down Expand Up @@ -365,6 +409,123 @@ function annotateQuote(quote: Quote, descriptor: PricingDescriptor): Quote {
}
}

async function parsePreflightQuoteResponse(response: Response): Promise<PreflightQuote> {
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<string, unknown>)[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
}
6 changes: 4 additions & 2 deletions src/conventions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -96,6 +97,7 @@ export async function discoverHyperbeamAoBundlerProfile(
policyId: HYPERBEAM_BUNDLER_FREE_TIER_POLICY_ID,
},
quoteConsumesQuota: false,
preflightConsumesQuota: true,
resource: "arweave-bytes",
},
},
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export type {
PaidRequestResult,
PaymentImportDescriptor,
PaymentNodeDescriptor,
PreflightAutoRequest,
PreflightDecision,
PreflightQuote,
PreflightRequest,
PricedAction,
PricingDescriptor,
PricingQuotaDescriptor,
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface PricingDescriptor {
action: string
method?: "GET" | "POST"
body?: Record<string, string>
preflightPath?: string
query?: Record<string, string>
quotePath?: string
quoteSemantics?: PricingQuoteSemantics
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -150,12 +161,26 @@ export interface QuoteRequest {
profile: HyperbalanceProfile
}

export interface PreflightRequest {
action: string
params?: Record<string, string | number | bigint | boolean>
profile: HyperbalanceProfile
request: HyperbeamSignedRequestFields
}

export interface QuoteAutoRequest {
action: string
params?: Record<string, string | number | bigint | boolean>
profile?: HyperbalanceProfile
}

export interface PreflightAutoRequest {
action: string
params?: Record<string, string | number | bigint | boolean>
profile?: HyperbalanceProfile
request: HyperbeamSignedRequestFields
}

export interface PaidRequestQuote {
action: string
params?: Record<string, string | number | bigint | boolean>
Expand Down
Loading