Skip to main content

Storage Interfaces

Key0 defines three storage interfaces that back the challenge lifecycle. All state mutations go through these interfaces to guarantee atomicity and prevent race conditions.
  • IChallengeStore — Manages challenge records and atomic state transitions.
  • ISeenTxStore — Prevents double-spend by tracking consumed transaction hashes.
  • IAuditStore — Optional write-only audit trail for every state transition.

IChallengeStore

The primary store. Every challenge flows through create and one or more transition calls.
interface IChallengeStore {
  get(challengeId: string): Promise<ChallengeRecord | null>;
  findActiveByRequestId(requestId: string): Promise<ChallengeRecord | null>;
  create(record: ChallengeRecord, meta?: TransitionMeta): Promise<void>;
  transition(
    challengeId: string,
    fromState: ChallengeState,
    toState: ChallengeState,
    updates?: ChallengeTransitionUpdates,
    meta?: TransitionMeta,
  ): Promise<boolean>;
  findPendingForRefund(minAgeMs: number): Promise<ChallengeRecord[]>;
}

Methods

get
(challengeId: string) => Promise<ChallengeRecord | null>
Retrieve a challenge by its unique identifier. Returns null if the challenge does not exist.
findActiveByRequestId
(requestId: string) => Promise<ChallengeRecord | null>
Find a non-expired challenge in the PENDING state for the given requestId. Used for idempotency: if the same client sends the same requestId twice, the engine returns the existing challenge instead of creating a duplicate. Returns null when no active challenge exists.
create
(record: ChallengeRecord, meta?: TransitionMeta) => Promise<void>
Persist a new challenge record. Implementations must reject with an error if a record with the same challengeId already exists. The optional meta parameter is forwarded to the audit store when one is configured.
transition
(challengeId, fromState, toState, updates?, meta?) => Promise<boolean>
Atomically move a challenge from fromState to toState, optionally writing additional fields via updates. Returns true if the transition succeeded, or false if the current state did not match fromState (optimistic concurrency control). This is the only safe way to change a challenge’s state.
findPendingForRefund
(minAgeMs: number) => Promise<ChallengeRecord[]>
Return all records in the PAID state whose paidAt timestamp is older than minAgeMs milliseconds and that have a fromAddress set. Used by the refund cron to locate undelivered payments eligible for automatic refund.
Never write to a challenge record directly. Always use transition() to change state. Direct writes bypass optimistic concurrency checks and can corrupt the challenge lifecycle.

ISeenTxStore

Guards against double-spend attacks by ensuring each on-chain transaction hash is consumed at most once.
interface ISeenTxStore {
  get(txHash: `0x${string}`): Promise<string | null>;
  markUsed(txHash: `0x${string}`, challengeId: string): Promise<boolean>;
}

Methods

get
(txHash: `0x${string}`) => Promise<string | null>
Check whether a transaction hash has already been claimed. Returns the challengeId it was used for, or null if the hash is unclaimed.
markUsed
(txHash: `0x${string}`, challengeId: string) => Promise<boolean>
Atomically mark a transaction hash as consumed for the given challenge. Returns true if the hash was successfully stored, or false if it was already claimed (double-spend attempt). Implementations must use an atomic set-if-not-exists operation (e.g., Redis SET NX, Postgres INSERT ... ON CONFLICT DO NOTHING).

IAuditStore

An optional, write-only audit trail. When configured, every call to IChallengeStore.create and IChallengeStore.transition also appends an entry here. Implementations must not expose update or delete operations.
interface IAuditStore {
  append(entry: Omit<AuditEntry, "id">): Promise<void>;
  getHistory(challengeId: string): Promise<AuditEntry[]>;
}

Methods

append
(entry: Omit<AuditEntry, 'id'>) => Promise<void>
Append a single audit entry. The id field is omitted from the input and assigned by the store (e.g., BIGSERIAL in Postgres, auto-incrementing index in Redis).
getHistory
(challengeId: string) => Promise<AuditEntry[]>
Retrieve the full transition history for a challenge, ordered chronologically. Useful for debugging and compliance.

Supporting Types

TransitionMeta

Metadata attached to create and transition calls, forwarded to the audit store.
type AuditActor = "engine" | "cron" | "admin" | "system";

type TransitionMeta = {
  readonly actor: AuditActor;
  readonly reason?: string;
};
FieldTypeDescription
actorAuditActorWho or what triggered the state transition.
reasonstring (optional)Human-readable explanation for the transition.

AuditEntry

A single immutable record of a state transition.
type AuditEntry = {
  readonly id?: string | number;
  readonly challengeId: string;
  readonly requestId: string;
  readonly clientAgentId?: string;
  readonly fromState: ChallengeState | null;
  readonly toState: ChallengeState;
  readonly actor: AuditActor;
  readonly reason?: string;
  readonly updates: Record<string, unknown> | null;
  readonly createdAt: Date;
};
FieldTypeDescription
idstring | numberStore-assigned identifier (BIGSERIAL for Postgres, index for Redis).
challengeIdstringThe challenge this entry belongs to.
requestIdstringThe original request ID. Survives challenge cleanup.
clientAgentIdstring (optional)The agent that initiated the payment flow.
fromStateChallengeState | nullPrevious state. null for the initial creation entry.
toStateChallengeStateState after the transition.
actorAuditActorWho triggered the transition (engine, cron, admin, or system).
reasonstring (optional)Human-readable reason for the transition.
updatesRecord<string, unknown> | nullSnapshot of the fields changed alongside the transition.
createdAtDateTimestamp of when the transition occurred.

ChallengeTransitionUpdates

Fields that may be written alongside a state transition. Defined as a partial pick from ChallengeRecord.
type ChallengeTransitionUpdates = Partial<
  Pick<
    ChallengeRecord,
    | "txHash"
    | "paidAt"
    | "accessGrant"
    | "fromAddress"
    | "deliveredAt"
    | "refundTxHash"
    | "refundedAt"
    | "refundError"
  >
>;
FieldTypeWritten during
txHashstringPENDING to PAID
paidAtDatePENDING to PAID
accessGrantobjectPENDING to PAID
fromAddressstringPENDING to PAID
deliveredAtDatePAID to DELIVERED
refundTxHashstringREFUND_PENDING to REFUNDED
refundedAtDateREFUND_PENDING to REFUNDED
refundErrorstringREFUND_PENDING to REFUND_FAILED

Built-in Implementations

Key0 ships with Redis and Postgres implementations for all three interfaces.
ClassInterfaceBackend
RedisChallengeStoreIChallengeStoreRedis
RedisSeenTxStoreISeenTxStoreRedis
RedisAuditStoreIAuditStoreRedis
PostgresChallengeStoreIChallengeStorePostgres
PostgresSeenTxStoreISeenTxStorePostgres
PostgresAuditStoreIAuditStorePostgres
Redis implementations use atomic Lua scripts for transition and markUsed to guarantee correctness under concurrent access. Postgres implementations rely on row-level locking and INSERT ... ON CONFLICT for the same guarantees.
IChallengeStore and ISeenTxStore are required. IAuditStore is optional. When omitted, state transitions still function correctly but no audit trail is recorded.
For connection setup, environment variables, and deployment guidance, see the Storage configuration guide.