@idoa/actionforge-chain
v0.1.1
Published
Safe-by-default state/chaining helpers for Solana Actions/Blinks
Downloads
208
Maintainers
Readme
@idoa/actionforge-chain
Safe-by-default state and chaining primitives for multi-step Solana Actions/Blinks handlers.
Provides encode/decode, expiry enforcement, step counting, idempotency keys, and replay protection for stateful action chains. State is passed between steps as a compact base64url token - no database required.
This package does not modify any Solana protocol or spec. It provides generic, framework-agnostic state safety helpers.
Installation
npm install @idoa/actionforge-chainRequires Node.js >= 18.
How It Works
Solana Actions can be chained across multiple HTTP requests. actionforge-chain lets you encode a small state object into a URL-safe string and pass it through each step. On every request:
- Decode the token from the incoming request
- Enforce expiry and step limits
- Advance to the next step
- Encode the new state and embed it in the next action's
href
Quick Start
import {
createChainState,
encodeState,
decodeState,
enforceExpiry,
enforceMaxSteps,
nextStep,
} from '@idoa/actionforge-chain';
// Step 1 - create initial state (e.g. on first action request)
const initial = createChainState({ ttlMs: 5 * 60_000, maxSteps: 3 });
const token = encodeState(initial);
// Pass `token` to next step via href query param
// Step 2 - receive state at next step
const state = decodeState(token);
enforceExpiry(state); // throws ChainError if expired
enforceMaxSteps(state); // throws ChainError if step limit reached
const updated = nextStep(state, { tx: 'abc123...' });
const nextToken = encodeState(updated);
// Embed `nextToken` in the next action's hrefAPI Reference
createChainState(options?): ChainState
Creates a new chain state. Call this at the start of a multi-step flow.
const state = createChainState({
ttlMs: 300_000, // TTL in milliseconds (default: 5 min)
maxSteps: 5, // Maximum allowed steps (default: 5)
idempotencyKey: '...', // Optional - auto-generated UUID if omitted
meta: { userId: 42 }, // Optional custom metadata
});encodeState(state: ChainState): string
Serializes a ChainState to a compact base64url string safe for use in URLs.
const token = encodeState(state);
// e.g. "eyJ2ZXJzaW9uIjoxLCJpZGVtcG90..."decodeState(encoded: string): ChainState
Deserializes a base64url token back to a ChainState. Throws ChainError (AFC1001) if the token is malformed or fields are missing.
const state = decodeState(token);enforceExpiry(state: ChainState, now?: number): ChainState
Throws ChainError (AFC1002) if the chain state has expired. Optionally pass now (ms) for deterministic testing.
enforceExpiry(state); // uses Date.now()
enforceExpiry(state, Date.now()); // explicitenforceMaxSteps(state: ChainState): ChainState
Throws ChainError (AFC1003) if state.step >= state.maxSteps.
enforceMaxSteps(state);nextStep(currentState: ChainState, actionResult: unknown): ChainState
Validates expiry and step limits, then returns a new ChainState with step incremented and actionResult fingerprinted into history. Does not mutate the input.
const updated = nextStep(state, { tx: 'signature123' });idempotencyKeyFrom(parts: Array<string | number>): string
Deterministically generates a SHA-256 idempotency key from an array of values. Useful for building keys from user pubkey + step + nonce.
const key = idempotencyKeyFrom(['userPubkey', 2, 'nonce42']);isReplay(idempotencyKey: string, seenKeys: Set<string>): boolean
Returns true if this key has been seen before (i.e. replay). Adds the key to seenKeys on first call.
const seen = new Set<string>();
isReplay(key, seen); // false - first time
isReplay(key, seen); // true - replay detectedType Reference
ChainState
interface ChainState {
version: 1;
idempotencyKey: string;
step: number;
maxSteps: number;
createdAt: number; // Unix ms
expiresAt: number; // Unix ms
history: string[]; // SHA-256 fingerprints of past results
meta?: Record<string, unknown>;
}CreateChainStateOptions
interface CreateChainStateOptions {
ttlMs?: number; // default: 300_000 (5 min)
maxSteps?: number; // default: 5
idempotencyKey?: string; // default: random UUID
now?: number; // override for testing
meta?: Record<string, unknown>;
}ChainError
Thrown by enforceExpiry, enforceMaxSteps, decodeState, and nextStep.
class ChainError extends Error {
code: ChainErrorCode; // 'AFC1001' | 'AFC1002' | 'AFC1003' | 'AFC1004'
}Error Codes
| Code | Description |
|------|-------------|
| AFC1001 | Invalid or malformed chain state token |
| AFC1002 | Chain state has expired |
| AFC1003 | Maximum steps exceeded |
| AFC1004 | Replay detected |
Related Packages
@idoa/actionforge-validator- Schema validation and linting@idoa/actionforge-harness- Conformance harness CLI for endpoint testing
License
MIT (c) Milan Matejic
