UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

223 lines (197 loc) • 9.52 kB
import { describe, expect, it } from "bun:test" import { MAX_LIFETIME } from "../consts" import { ExpirationTime } from "../utils/expirationTime" import { InvalidExpiryError } from "./errors" import { type Expiry, type ExpiryContext, resolveExpiry, toBlocks } from "./expiry" import { MAX_EXPIRES_AT } from "./params" const CURRENT_BLOCK = 1000n const ctx: ExpiryContext = { currentBlock: CURRENT_BLOCK } describe("what ExpirationTime builds", () => { it("is the wire pair itself, so what you write is what is sent", () => { expect(ExpirationTime.fromDays(30)).toEqual({ minLifetime: 1_296_000n, expiresAt: 0n } as never) expect(ExpirationTime.atBlock(1_200_000n)).toEqual({ minLifetime: 0n, expiresAt: 1_200_000n, } as never) }) it("is plain data — no prototype, no methods to lose", () => { const expiry = ExpirationTime.fromDays(1) expect(Object.getPrototypeOf(expiry)).toBe(Object.prototype) expect(structuredClone(expiry)).toEqual(expiry as never) }) it("puts a duration in minLifetime and a deadline in expiresAt, never the other way round", () => { // Which slot a value lands in is what decides the rules that govern it. for (const lifetime of [ ExpirationTime.fromSeconds(120), ExpirationTime.fromMinutes(1), ExpirationTime.fromHours(1), ExpirationTime.fromDays(1), ExpirationTime.fromWeeks(1), ExpirationTime.fromMonths(1), ExpirationTime.fromYears(1), ExpirationTime.fromBlocks(3), ]) { expect(lifetime.expiresAt).toBe(0n) expect(lifetime.minLifetime > 0n).toBe(true) } expect(ExpirationTime.atBlock(5n).minLifetime).toBe(0n) expect(ExpirationTime.atDate(new Date(Date.now() + 60_000)).minLifetime).toBe(0n) }) }) describe("durations — the everyday case", () => { it("turns a duration into a lifetime, with no block arithmetic at the call site", () => { const resolved = resolveExpiry(ExpirationTime.fromDays(30), ctx) // 30 days at 2s/block. expect(resolved.minLifetime).toBe(1_296_000n) expect(resolved.expiresAt).toBe(0n) expect(resolved.target).toBe(CURRENT_BLOCK + 1_296_000n) }) it("counts blocks exactly, with no conversion to be approximate about", () => { expect(resolveExpiry(ExpirationTime.fromBlocks(7), ctx).minLifetime).toBe(7n) expect(() => ExpirationTime.fromBlocks(0)).toThrow(/positive whole number of blocks/) expect(() => ExpirationTime.fromBlocks(1.5)).toThrow(InvalidExpiryError) }) it("refuses a duration that is not a whole number of blocks, at the call site", () => { // 3s is a block and a half. Rounding it would silently grant 4s, so it is rejected instead — // and it throws where it is written, not later where it is sent. expect(() => ExpirationTime.fromSeconds(3)).toThrow(/multiple of the 2s block time/) expect(() => ExpirationTime.fromSeconds(999)).toThrow(/Try 998 or 1000/) expect(() => ExpirationTime.fromSeconds(0)).toThrow(/positive whole number of seconds/) expect(() => ExpirationTime.fromSeconds(-2)).toThrow(InvalidExpiryError) expect(() => ExpirationTime.fromSeconds(1.5)).toThrow(InvalidExpiryError) }) }) describe("deadlines — the absolute case", () => { it("takes a block height as given", () => { const resolved = resolveExpiry(ExpirationTime.atBlock(5_000n), ctx) expect(resolved.expiresAt).toBe(5_000n) expect(resolved.minLifetime).toBe(0n) expect(resolved.target).toBe(5_000n) }) it("places a Date against the block the transaction is built on", () => { const inAnHour = ExpirationTime.atDate(new Date(Date.now() + 3600_000)) const resolved = resolveExpiry(inAnHour, ctx) // 3600s at 2s/block, counted from the current block. expect(resolved.expiresAt).toBe(CURRENT_BLOCK + 1800n) expect(resolved.target).toBe(CURRENT_BLOCK + 1800n) }) it("rounds a Date up rather than rejecting it, unlike a duration", () => { // A wall-clock instant becomes a block HEIGHT, which is whole by construction — there is no // divisibility question to fail. `fromSeconds(3)` would have thrown; this cannot. const odd = ExpirationTime.atDate(new Date(Date.now() + 3_000)) expect(resolveExpiry(odd, ctx).target).toBe(CURRENT_BLOCK + 2n) }) it("holds the Date until it is resolved, so the value does not depend on when it was built", () => { const date = new Date(Date.now() + 3600_000) const expiry = ExpirationTime.atDate(date) expect(expiry.expiresAt).toEqual(date) // The same value resolves against whatever block it is later sent with. expect(resolveExpiry(expiry, { ...ctx, currentBlock: 9_000n }).target).toBe(9_000n + 1800n) }) it("rejects a deadline already gone", () => { expect(() => resolveExpiry(ExpirationTime.atDate(new Date(Date.now() - 1000)), ctx)).toThrow( /in the past/, ) expect(() => resolveExpiry(ExpirationTime.atBlock(CURRENT_BLOCK - 1n), ctx)).toThrow( /dead on arrival/, ) expect(() => resolveExpiry(ExpirationTime.atBlock(CURRENT_BLOCK), ctx)).toThrow( /dead on arrival/, ) }) it("rejects something that is neither a block height nor a Date", () => { expect(() => ExpirationTime.atBlock(-1n)).toThrow(/non-negative block height/) expect(() => ExpirationTime.atDate(new Date("nonsense"))).toThrow(/valid Date/) expect(() => ExpirationTime.atBlock(5 as never)).toThrow(/non-negative block height/) }) }) describe("a deadline with a floor", () => { it("lives until whichever is later", () => { // The deadline is further out than the floor. const deadlineWins = ExpirationTime.atBlock(5_000n, { atLeast: ExpirationTime.fromSeconds(400), }) expect(resolveExpiry(deadlineWins, ctx).target).toBe(5_000n) // The floor is further out than the deadline. const floorWins = ExpirationTime.atBlock(1_100n, { atLeast: ExpirationTime.fromDays(1) }) expect(resolveExpiry(floorWins, ctx).target).toBe(CURRENT_BLOCK + 43_200n) }) it("puts both on the wire, leaving the engine to resolve them the same way", () => { const resolved = resolveExpiry( ExpirationTime.atBlock(5_000n, { atLeast: ExpirationTime.fromSeconds(200) }), ctx, ) expect(resolved.expiresAt).toBe(5_000n) expect(resolved.minLifetime).toBe(100n) }) it("works the same on a date deadline", () => { const expiry = ExpirationTime.atDate(new Date(Date.now() + 2_000), { atLeast: ExpirationTime.fromDays(1), }) // The date is only a second or two out, so the floor is what keeps the entity alive. expect(resolveExpiry(expiry, ctx).target).toBe(CURRENT_BLOCK + 43_200n) }) it("treats an omitted floor as no floor", () => { expect(ExpirationTime.atBlock(5_000n, {}).minLifetime).toBe(0n) expect(ExpirationTime.atBlock(5_000n, { atLeast: undefined }).minLifetime).toBe(0n) }) }) describe("bounds and misuse", () => { it("refuses an expiry that says nothing", () => { // Only reachable from JS, or by hand-building the pair — the type requires ExpirationTime. const nothing = { minLifetime: 0n, expiresAt: 0n } as unknown as Expiry expect(() => resolveExpiry(nothing, ctx)).toThrow(/no expiry given/) }) it("names ExpirationTime when handed something it did not build", () => { expect(() => resolveExpiry(2_592_000 as unknown as Expiry, ctx)).toThrow( /must be built with ExpirationTime/, ) expect(() => resolveExpiry(undefined as unknown as Expiry, ctx)).toThrow( /must be built with ExpirationTime/, ) }) }) describe("the maximum lifetime", () => { it("is what ExpirationTime.max() asks for, as a duration", () => { expect(ExpirationTime.max()).toEqual({ minLifetime: MAX_LIFETIME, expiresAt: 0n } as never) expect(resolveExpiry(ExpirationTime.max(), ctx).target).toBe(CURRENT_BLOCK + MAX_LIFETIME) }) it("refuses a duration even one block longer", () => { expect(resolveExpiry(ExpirationTime.fromBlocks(Number(MAX_LIFETIME)), ctx).minLifetime).toBe( MAX_LIFETIME, ) for (const tooLong of [ ExpirationTime.fromBlocks(Number(MAX_LIFETIME) + 1), ExpirationTime.fromYears(1), ExpirationTime.atBlock(5_000n, { atLeast: ExpirationTime.fromYears(1) }), ]) { expect(() => resolveExpiry(tooLong, ctx)).toThrow(/longer than the chain's maximum lifetime/) } }) it("refuses a deadline past the maximum from the earliest block the transaction can land in", () => { // The transaction lands in the block after the current one at the earliest, and the chain // counts from there. const latest = CURRENT_BLOCK + 1n + MAX_LIFETIME expect(resolveExpiry(ExpirationTime.atBlock(latest), ctx).target).toBe(latest) for (const tooFar of [ ExpirationTime.atBlock(latest + 1n), ExpirationTime.atBlock(MAX_EXPIRES_AT), ExpirationTime.atBlock(MAX_EXPIRES_AT + 1n), ExpirationTime.atDate(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)), ]) { expect(() => resolveExpiry(tooFar, ctx)).toThrow(/past block/) } }) }) describe("toBlocks", () => { it("converts whole blocks' worth of seconds", () => { expect(toBlocks(2)).toBe(1n) expect(toBlocks(4)).toBe(2n) expect(toBlocks(86_400)).toBe(43_200n) }) it("rejects anything that is not a positive multiple of the block time", () => { for (const bad of [1, 3, 999, 0, -2, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { expect(() => toBlocks(bad)).toThrow(InvalidExpiryError) } }) })