UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

185 lines (167 loc) • 7.9 kB
import { BLOCK_TIME, MAX_LIFETIME } from "../consts" import type { ExpirationTime } from "../utils/expirationTime" import { InvalidExpiryError } from "./errors" /** * The brand. Module-private and never exported, so the only way to obtain an {@link Expiry} is to * build one with {@link ExpirationTime} — a hand-written `{ minLifetime, expiresAt }` literal will * not typecheck, and neither will a bare number of seconds. */ declare const validated: unique symbol /** * How long an entity should live. * * This is the pair the ABI carries, and the engine resolves it as * `max(expiresAt, currentBlock + minLifetime)` — whichever is later. Build one with * {@link ExpirationTime}; there is no other way to make one. * * Which slot a value lands in also decides which rules govern it. `minLifetime` is a duration, so * it must come to a whole number of blocks. `expiresAt` is a block height, which is a whole number * by construction, so a wall-clock date rounds into it rather than being rejected. */ export type Expiry = { /** Lifetime floor in blocks, counted from the block the transaction lands in. `0n` if unset. */ readonly minLifetime: bigint /** * Absolute deadline: a block height, or a `Date` still to be placed against the current block. * `0n` if unset. */ readonly expiresAt: bigint | Date readonly [validated]: true } /** * An expiry that is nothing but a duration — what the `ExpirationTime.from*` helpers return. * * Distinguished from a full {@link Expiry} by its `expiresAt` being the literal `0n`, which is what * keeps `atBlock(x, { atLeast: atBlock(y) })` from compiling: a deadline is not a floor. */ export type Lifetime = Expiry & { readonly expiresAt: 0n } /** * Builds a branded {@link Expiry}. * * Internal seam for {@link ExpirationTime}, which is the only thing that should call it — it is not * part of the package's public surface. */ export function makeExpiry(minLifetime: bigint, expiresAt: bigint | Date): Expiry { return { minLifetime, expiresAt } as Expiry } /** The two `uint64` values the ABI carries, with every `Date` already placed. */ export type EncodedExpiry = { /** Absolute target block height, or `0n` for a purely relative lifetime. */ expiresAt: bigint /** Relative floor in blocks, or `0n` for a purely absolute expiry. */ minLifetime: bigint } /** What an expiry resolves to, given the chain's current block. */ export type ResolvedExpiry = EncodedExpiry & { /** The block the entity will expire at — `max(expiresAt, currentBlock + minLifetime)`. */ target: bigint } /** What the SDK needs to know about the chain to resolve an expiry. */ export type ExpiryContext = { currentBlock: bigint } /** * Resolves an expiry into the wire pair and the block the entity will expire at. * * A duration arrives here already in blocks — {@link ExpirationTime} converted it at the nominal * `BLOCK_TIME` (2 seconds) through {@link toBlocks} — and a `Date` deadline is placed against * `currentBlock` at that same block time here. Both are **approximate**: block production is not a * clock, so a lifetime asked for in days drifts if the chain runs fast or slow. The block the * expiry resolves to against `currentBlock` comes back as {@link ResolvedExpiry.target}, so the * approximation is always observable rather than assumed. * * @throws {InvalidExpiryError} If the expiry is malformed, would outlive the chain's * {@link MAX_LIFETIME}, or would leave the entity dead on arrival. */ export function resolveExpiry(expiry: Expiry, context: ExpiryContext): ResolvedExpiry { const { currentBlock } = context if (typeof expiry?.minLifetime !== "bigint" || expiry.expiresAt === undefined) { throw new InvalidExpiryError( "expiration must be built with ExpirationTime, e.g. ExpirationTime.fromDays(30) or " + "ExpirationTime.atBlock(1_200_000n)", ) } const { minLifetime } = expiry const absolute = typeof expiry.expiresAt === "bigint" ? expiry.expiresAt : blockAtDate(expiry.expiresAt, currentBlock) if (minLifetime === 0n && absolute === 0n) { throw new InvalidExpiryError( "no expiry given. Pass ExpirationTime.fromDays(30) for a lifetime, " + "ExpirationTime.atBlock(n) for an absolute deadline, or a deadline with " + "{ atLeast } to take whichever is later.", ) } // The chain counts the maximum lifetime from the block the transaction lands in, which is at the // earliest the one after `currentBlock`. A duration is measured from that same block, so it is // bounded exactly; a deadline is refused only when it is too far even for the earliest landing. // Both bounds sit far below the uint64 the wire carries, so they keep the values within it too. if (minLifetime > MAX_LIFETIME) { throw new InvalidExpiryError( `a lifetime of ${minLifetime} blocks is longer than the chain's maximum lifetime of ` + `${MAX_LIFETIME} blocks (about 182 days). Use ExpirationTime.max() for the longest ` + "lifetime allowed.", ) } const latestDeadline = currentBlock + 1n + MAX_LIFETIME if (absolute > latestDeadline) { throw new InvalidExpiryError( `expiresAt resolves to block ${absolute}, past block ${latestDeadline}: the chain's ` + `maximum lifetime is ${MAX_LIFETIME} blocks (about 182 days) after the block the ` + `transaction lands in, and the current block is ${currentBlock}. Use ` + "ExpirationTime.max() for the longest lifetime allowed.", ) } const relative = minLifetime === 0n ? 0n : currentBlock + minLifetime const target = absolute > relative ? absolute : relative if (target <= currentBlock) { throw new InvalidExpiryError( `the entity would expire at block ${target}, which is not after the current block ` + `${currentBlock}, so it would be dead on arrival`, ) } return { expiresAt: absolute, minLifetime, target } } /** * A duration in seconds as a whole number of blocks. * * Arkiv counts lifetimes in blocks, so a duration that is not a whole number of them does not * describe anything the chain can store. Rather than silently round a caller's `3` up to two blocks * — four seconds, not what they asked for — this rejects it: a duration must be a positive multiple * of `BLOCK_TIME` (2 seconds). The {@link ExpirationTime} helpers all produce such multiples. * * @throws {InvalidExpiryError} If the duration is not a positive whole number of blocks' seconds. */ export function toBlocks(seconds: number): bigint { if (!Number.isInteger(seconds) || seconds <= 0) { throw new InvalidExpiryError( `a lifetime must be a positive whole number of seconds, got ${seconds}. Use the ` + "ExpirationTime helpers, e.g. ExpirationTime.fromDays(30).", ) } if (seconds % BLOCK_TIME !== 0) { throw new InvalidExpiryError( `a lifetime must be a multiple of the ${BLOCK_TIME}s block time, got ${seconds}, which is ` + `${seconds / BLOCK_TIME} blocks. Try ${seconds - (seconds % BLOCK_TIME)} or ` + `${seconds + BLOCK_TIME - (seconds % BLOCK_TIME)}, or ExpirationTime.fromBlocks(n).`, ) } return BigInt(seconds / BLOCK_TIME) } /** * The block a wall-clock instant lands on. * * Rounds **up**: a date almost never falls on a block boundary, and unlike a duration — which the * caller states and can state exactly — there is nothing here to reject, so the entity lives until * at least the instant asked for. */ function blockAtDate(date: Date, currentBlock: bigint): bigint { const secondsAway = (date.getTime() - Date.now()) / 1000 if (secondsAway <= 0) { throw new InvalidExpiryError( `the deadline ${date.toISOString()} is in the past, so the entity would be dead on arrival`, ) } return currentBlock + BigInt(Math.ceil(secondsAway / BLOCK_TIME)) }