@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
223 lines (197 loc) • 9.52 kB
text/typescript
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)
}
})
})