From e3c587f53df1a53db2f31ac362e1b8c66feb861f Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Wed, 6 May 2026 23:48:00 -0400 Subject: [PATCH] Fix Bulletin allowance parsing --- .changeset/fresh-quota-path.md | 5 ++ src/utils/account/allowance.test.ts | 56 ++++++++++++++++--- src/utils/account/allowance.ts | 6 +- src/utils/account/attestation.test.ts | 38 ++++++++++++- src/utils/account/attestation.ts | 6 +- src/utils/account/authorizationExtent.test.ts | 55 ++++++++++++++++++ src/utils/account/authorizationExtent.ts | 55 ++++++++++++++++++ 7 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 .changeset/fresh-quota-path.md create mode 100644 src/utils/account/authorizationExtent.test.ts create mode 100644 src/utils/account/authorizationExtent.ts diff --git a/.changeset/fresh-quota-path.md b/.changeset/fresh-quota-path.md new file mode 100644 index 0000000..58db7a5 --- /dev/null +++ b/.changeset/fresh-quota-path.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Read current Bulletin authorization allowance fields correctly during deploy preflight. diff --git a/src/utils/account/allowance.test.ts b/src/utils/account/allowance.test.ts index b55134d..c28bd87 100644 --- a/src/utils/account/allowance.test.ts +++ b/src/utils/account/allowance.test.ts @@ -58,20 +58,45 @@ describe("checkAllowance", () => { expect(result.remainingBytes).toBe(0n); }); - it("returns authorized with remaining quota", async () => { + it("returns remaining quota from current runtime allowance fields", async () => { const { client } = makeClient({ - extent: { transactions: 500, bytes: 50_000_000n }, + extent: { + transactions: 250, + transactions_allowance: 1000, + bytes: 12_500_000n, + bytes_allowance: 100_000_000n, + }, expiration: 999999, }); const result = await checkAllowance(client, "5GrwvaEF..."); expect(result.authorized).toBe(true); - expect(result.remainingTxs).toBe(500); - expect(result.remainingBytes).toBe(50_000_000n); + expect(result.remainingTxs).toBe(750); + expect(result.remainingBytes).toBe(87_500_000n); + }); + + it("clamps current runtime remaining quota at zero", async () => { + const { client } = makeClient({ + extent: { + transactions: 1000, + transactions_allowance: 1000, + bytes: 100_000_000n, + bytes_allowance: 100_000_000n, + }, + expiration: 999999, + }); + const result = await checkAllowance(client, "5GrwvaEF..."); + expect(result.remainingTxs).toBe(0); + expect(result.remainingBytes).toBe(0n); }); it("returns authorized even with low remaining txs", async () => { const { client } = makeClient({ - extent: { transactions: 5, bytes: 1_000_000n }, + extent: { + transactions: 95, + transactions_allowance: 100, + bytes: 0n, + bytes_allowance: 1_000_000n, + }, expiration: 100, }); const result = await checkAllowance(client, "5GrwvaEF..."); @@ -93,7 +118,12 @@ describe("ensureAllowance", () => { it("skips granting when allowance is sufficient", async () => { mockSubmitAndWatch.mockClear(); const { client } = makeClient({ - extent: { transactions: 500, bytes: 50_000_000n }, + extent: { + transactions: 500, + transactions_allowance: 1000, + bytes: 50_000_000n, + bytes_allowance: 100_000_000n, + }, expiration: 999999, }); await ensureAllowance(client, "5GrwvaEF..."); @@ -118,7 +148,12 @@ describe("ensureAllowance", () => { it(`re-grants allowance when remaining txs are below LOW_TX_THRESHOLD (${LOW_TX_THRESHOLD})`, async () => { mockSubmitAndWatch.mockClear(); const { client } = makeClient({ - extent: { transactions: LOW_TX_THRESHOLD - 5, bytes: 1_000_000n }, + extent: { + transactions: 95, + transactions_allowance: LOW_TX_THRESHOLD + 90, + bytes: 0n, + bytes_allowance: 1_000_000n, + }, expiration: 100, }); await ensureAllowance(client, "5GrwvaEF..."); @@ -128,7 +163,12 @@ describe("ensureAllowance", () => { it(`skips granting at exactly LOW_TX_THRESHOLD (${LOW_TX_THRESHOLD})`, async () => { mockSubmitAndWatch.mockClear(); const { client } = makeClient({ - extent: { transactions: LOW_TX_THRESHOLD, bytes: 10_000_000n }, + extent: { + transactions: 90, + transactions_allowance: LOW_TX_THRESHOLD + 90, + bytes: 0n, + bytes_allowance: 10_000_000n, + }, expiration: 100, }); await ensureAllowance(client, "5GrwvaEF..."); diff --git a/src/utils/account/allowance.ts b/src/utils/account/allowance.ts index 2c96f82..8bc696a 100644 --- a/src/utils/account/allowance.ts +++ b/src/utils/account/allowance.ts @@ -8,6 +8,7 @@ import { Enum } from "polkadot-api"; import { submitAndWatch, createDevSigner } from "@polkadot-apps/tx"; import type { PaseoClient } from "../connection.js"; +import { remainingAuthorizationExtent } from "./authorizationExtent.js"; const AT_BEST = { at: "best" as const }; @@ -39,10 +40,11 @@ export async function checkAllowance( return { authorized: false, remainingTxs: 0, remainingBytes: 0n }; } + const remaining = remainingAuthorizationExtent(raw.extent); return { authorized: true, - remainingTxs: raw.extent.transactions, - remainingBytes: raw.extent.bytes, + remainingTxs: remaining.transactions, + remainingBytes: remaining.bytes, }; } diff --git a/src/utils/account/attestation.test.ts b/src/utils/account/attestation.test.ts index 1289fc6..507228d 100644 --- a/src/utils/account/attestation.test.ts +++ b/src/utils/account/attestation.test.ts @@ -139,7 +139,15 @@ describe("checkAttestation", () => { it("derives remainingBlocks from expiration - currentBlock", async () => { const client = makeClient( - { extent: { transactions: 500, bytes: 50_000_000n }, expiration: 1000 }, + { + extent: { + transactions: 500, + transactions_allowance: 1000, + bytes: 50_000_000n, + bytes_allowance: 100_000_000n, + }, + expiration: 1000, + }, 200, ); const s = await checkAttestation(client, "5GrwvaEF"); @@ -151,9 +159,35 @@ describe("checkAttestation", () => { expect(s.remainingBytes).toBe(50_000_000n); }); + it("normalizes current runtime allowance fields to remaining quota", async () => { + const client = makeClient( + { + extent: { + transactions: 10, + transactions_allowance: 100, + bytes: 25_000_000n, + bytes_allowance: 100_000_000n, + }, + expiration: 1000, + }, + 200, + ); + const s = await checkAttestation(client, "5GrwvaEF"); + expect(s.remainingTxs).toBe(90); + expect(s.remainingBytes).toBe(75_000_000n); + }); + it("marks as expired when expiration has passed", async () => { const client = makeClient( - { extent: { transactions: 10, bytes: 1_000_000n }, expiration: 200 }, + { + extent: { + transactions: 0, + transactions_allowance: 10, + bytes: 0n, + bytes_allowance: 1_000_000n, + }, + expiration: 200, + }, 500, ); const s = await checkAttestation(client, "5GrwvaEF"); diff --git a/src/utils/account/attestation.ts b/src/utils/account/attestation.ts index dd6393d..bb02e6e 100644 --- a/src/utils/account/attestation.ts +++ b/src/utils/account/attestation.ts @@ -13,6 +13,7 @@ import { Enum } from "polkadot-api"; import type { PaseoClient } from "../connection.js"; +import { remainingAuthorizationExtent } from "./authorizationExtent.js"; const AT_BEST = { at: "best" as const }; @@ -51,13 +52,14 @@ export async function checkAttestation( } const remainingBlocks = Math.max(0, raw.expiration - currentBlock); + const remaining = remainingAuthorizationExtent(raw.extent); return { authorized: true, expired: remainingBlocks === 0, remainingBlocks, expiresAt: raw.expiration, - remainingTxs: raw.extent.transactions, - remainingBytes: raw.extent.bytes, + remainingTxs: remaining.transactions, + remainingBytes: remaining.bytes, }; } diff --git a/src/utils/account/authorizationExtent.test.ts b/src/utils/account/authorizationExtent.test.ts new file mode 100644 index 0000000..ae9f75d --- /dev/null +++ b/src/utils/account/authorizationExtent.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { remainingAuthorizationExtent } from "./authorizationExtent.js"; + +describe("remainingAuthorizationExtent", () => { + it("subtracts used quota from granted allowance", () => { + expect( + remainingAuthorizationExtent({ + transactions: 250, + transactions_allowance: 1000, + bytes: 12_500_000n, + bytes_allowance: 100_000_000n, + }), + ).toEqual({ + transactions: 750, + bytes: 87_500_000n, + }); + }); + + it("treats zero usage as full remaining allowance", () => { + expect( + remainingAuthorizationExtent({ + transactions: 0, + transactions_allowance: 3000, + bytes: 0n, + bytes_allowance: 300_000_000n, + }), + ).toEqual({ + transactions: 3000, + bytes: 300_000_000n, + }); + }); + + it("clamps over-consumed quota at zero", () => { + expect( + remainingAuthorizationExtent({ + transactions: 1001, + transactions_allowance: 1000, + bytes: 101_000_000n, + bytes_allowance: 100_000_000n, + }), + ).toEqual({ + transactions: 0, + bytes: 0n, + }); + }); + + it("rejects legacy extents without current allowance fields", () => { + expect(() => + remainingAuthorizationExtent({ + transactions: 1000, + bytes: 100_000_000n, + }), + ).toThrow(/transactions_allowance/); + }); +}); diff --git a/src/utils/account/authorizationExtent.ts b/src/utils/account/authorizationExtent.ts new file mode 100644 index 0000000..9ad6e95 --- /dev/null +++ b/src/utils/account/authorizationExtent.ts @@ -0,0 +1,55 @@ +/** + * Current Bulletin authorization extents expose used counters plus granted + * allowance totals. The CLI only needs remaining quota for preflight decisions + * and `dot init` display. + */ + +export interface AuthorizationExtent { + transactions: bigint | number; + transactions_allowance: bigint | number; + bytes: bigint | number; + bytes_allowance: bigint | number; +} + +export interface RemainingAuthorizationExtent { + transactions: number; + bytes: bigint; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getNumericField( + extent: Record, + field: keyof AuthorizationExtent, +): bigint { + const value = extent[field]; + if (typeof value !== "bigint" && typeof value !== "number") { + throw new Error(`Bulletin authorization extent is missing current field "${field}"`); + } + return BigInt(value); +} + +function remaining(allowance: bigint, used: bigint): bigint { + return allowance > used ? allowance - used : 0n; +} + +export function remainingAuthorizationExtent(extent: unknown): RemainingAuthorizationExtent { + if (!isRecord(extent)) { + throw new Error("Bulletin authorization extent is malformed"); + } + + return { + transactions: Number( + remaining( + getNumericField(extent, "transactions_allowance"), + getNumericField(extent, "transactions"), + ), + ), + bytes: remaining( + getNumericField(extent, "bytes_allowance"), + getNumericField(extent, "bytes"), + ), + }; +}