Documentation
The four primitives — saga, invariant, idempotent, receipted — are higher-order functions you compose around an action. Wrap once; the wrapped function becomes crash-safe, deduplicated, invariant-enforced, and audit-trailed.
All four share the same shape: (opts) => (fn) => (args) => Promise<R>. The recommended outer-to-inner order is receipted → invariant → idempotent → saga; reversals are caught by checkComposition().
117 vitest tests (10 live on 0G Galileo) · 8 forge tests · 125 total · MIT licensed.
Install
Packages
Five packages under the @openacid npm scope. Start with @openacid/acid; add adapters as needed.
pnpm add @openacid/acid @openacid/adapter-memorypnpm add @openacid/adapter-viem @openacid/adapter-0g-storage @openacid/adapter-ensRequirements
- Node 20+. ESM + CJS dual builds; full
.d.tstypings. - viem 2.x as a peer dependency for the chain/signer/ens adapters.
- Funded testnet wallets only when you wire the live adapters; the memory adapter needs nothing.
Quickstart
The full pipeline. Save as agent.ts, set PRIVATE_KEY, run with tsx.
import { saga, invariant, idempotent, receipted } from '@openacid/acid'
import { MemoryStorageAdapter, MemorySigner } from '@openacid/adapter-memory'
const storage = new MemoryStorageAdapter()
const signer = new MemorySigner(process.env.PRIVATE_KEY as `0x${string}`)
const rebalance = receipted({
storage,
signer,
chain: { chainId: 16602 }, // 0G Galileo
fnName: 'rebalance',
})(
invariant({
pre: async (a) => a.amount > 0n,
post: async (_a, results) => results.swap !== undefined,
})(
idempotent({
key: (a) => `rebalance:${a.targetRatio}:${a.deadline}`,
storage,
ttl: 600,
})(
saga({
steps: [
{ id: 'approve', do: async () => approveTx() },
{ id: 'swap', do: async () => swapTx() },
],
compensations: { approve: async () => revokeTx() },
storage,
}),
),
),
)
await rebalance({ targetRatio: 60, amount: 1000n, deadline: deadline() })Primitives
Each primitive is a higher-order function. Compose them by nesting; the order is semantic.
saga
Multi-step transactions with compensation. Either every step commits or every executed step is reversed in reverse order.
Saga state is persisted per-step and content-addressed by hash(args). Identical args resume the same saga; on crash mid-step, the restart treats the in-flight step as failed and compensates prior steps. Compensations are themselves idempotent, keyed by ${sagaId}:compensate:${stepId}.
Options
| option | type | notes |
|---|---|---|
| steps | SagaStep<A>[] | ordered list; each has id and async do(ctx) |
| compensations | Record<stepId, CompensationFn> | reverse-order undo on failure |
| storage | StorageAdapter | persists saga state for crash recovery |
| onPartialFailure | 'compensate' | 'halt' | 'retry-forward' | default: compensate |
| namespace | string | storage key prefix; default 'saga' |
Step + Context
interface SagaStep<A> {
id: string
do: (ctx: SagaContext<A>) => Promise<unknown>
compensateOn?: 'failure' | 'never'
}
interface SagaContext<A> {
args: A
sagaId: string
attempt: number
results: Record<string, unknown>
}
type CompensationFn<A> = (ctx: SagaContext<A>, stepResult: unknown) => Promise<void>Example
import { saga } from '@openacid/acid'
const swap = saga<{ amountIn: bigint }>({
steps: [
{ id: 'approve', do: async (ctx) => approveTx(ctx.args.amountIn) },
{ id: 'swap', do: async (ctx) => swapTx(ctx.args.amountIn) },
{ id: 'stake', do: async (ctx) => stakeTx(ctx.results.swap) },
],
compensations: {
approve: async () => approveTx(0n), // revoke
swap: async () => null, // no-op (idempotent on chain)
},
storage,
})
await swap({ amountIn: 1000n })invariant
Pre/post predicate enforcement at action boundaries. pre rejects before the wrapped fn runs; post rejects after the fn returns when the produced state is invalid.
Predicates return true (pass) or an InvariantViolation { reason, severity, context }. The library exports five built-ins: noOrphanAllowances, balanceWithinBound, gasUnderCap, slippageBelow, recipientWhitelist.
Options
| option | type | notes |
|---|---|---|
| pre | (args, ctx) => Promise<bool | Violation> | reject before fn runs |
| post | (args, result, ctx) => Promise<bool | Violation> | reject after fn returns |
| onViolation | 'throw' | 'compensate' | 'log-only' | default: throw |
| compensate | (args, result, violation, phase) => Promise<void> | required when onViolation='compensate' |
| onLog | (violation, phase, args, result?) => void | consumer for log-only mode |
Example
import { invariant, noOrphanAllowances } from '@openacid/acid'
const guarded = invariant({
pre: async (args) => args.amount > 0n,
post: noOrphanAllowances({
getAllowances: async () => [
{ token: USDC, spender: router, amount: await readAllowance(...) },
],
}),
onViolation: 'throw',
})(swap)idempotent
Exactly-once execution under concurrent retries and crashes. The first call claims an in-flight marker via storage.cas; duplicate-during-execution either blocks (default), returns a pending handle, or rejects. Duplicate-after-completion returns the cached result without re-invoking the wrapped fn.
Strict-key mode catches non-deterministic keys by calling key(args) twice and comparing — Date.now() or Math.random()-based keys throw NonDeterministicKeyError.
Options
| option | type | notes |
|---|---|---|
| key | (args) => string | deterministic; rejected by strict-key check if it varies |
| storage | StorageAdapter | in-flight marker + result cache |
| inFlight | 'block' | 'return-pending' | 'reject' | default: block |
| ttl | number (seconds) | completed-result cache TTL; default 3600 |
| strictKeys | boolean | default true |
| pollIntervalMs | number | 'block' mode poll cadence; default 50 |
| blockTimeoutMs | number | give up waiting after N ms; default 30000 |
| namespace | string | storage key prefix; default 'idempotent' |
Example
import { idempotent } from '@openacid/acid'
const dedup = idempotent({
key: (a) => `rebalance:${a.targetRatio}:${a.deadline}`,
storage,
inFlight: 'block', // 'block' | 'return-pending' | 'reject'
ttl: 600, // cache for 10 min after completion
strictKeys: true, // reject Date.now()-based keys
})(action)receipted
Signed, chained, content-addressed receipts. Every wrapped call produces a Receipt persisted to the configured storage; receipts are EIP-712 signed by the configured signer with a domain that includes chainId, preventing cross-chain replay.
Receipts are emitted even when the wrapped fn throws — the audit trail covers attempts, not just successes. Use verifyReceipt(receipt, expectedSigner, domain) to recover the signer; it works on any EVM with no library required (the on-chain side is ecrecover(digest, v, r, s)).
Options
| option | type | notes |
|---|---|---|
| storage | StorageAdapter | where receipts persist (typically 0G Storage) |
| signer | SignerAdapter | signs the EIP-712 typed-data digest |
| chain | { chainId; verifyingContract? } | EIP-712 domain |
| fnName | string | human-readable identifier in the receipt |
| prevReceiptKey | string | agent-scoped key for receipt chaining |
| collectTxRefs | (result) => string[] | extract on-chain tx hashes for the receipt |
| onReceipt | (receipt) => Promise<void> | callback fired after persistence |
| namespace | string | storage key prefix; default 'receipt' |
Receipt shape
callId— content-addressed identifier (bytes32)prevReceipt— chain pointer (bytes32 | null)fnName,inputHash,outputHashtxRefs[]— on-chain tx hashes captured bycollectTxRefsstartedAt,endedAt,retriessignature— 65-byte secp256k1 over the EIP-712 digestcid— content-addressed pointer in the durable backend
Example
import { receipted, verifyReceipt } from '@openacid/acid'
const action = receipted({
storage,
signer,
chain: { chainId: 16602 },
fnName: 'rebalance',
prevReceiptKey: 'agent-1',
collectTxRefs: (r) => extractTxHashes(r),
onReceipt: async (receipt) => {
await mirrorToEns(receipt)
},
})(saga)
// later, anywhere — verify the receipt
const ok = await verifyReceipt(receipt, agentAddress, { chainId: 16602 })Composition
Each wrapper tags its returned function with a non-enumerable symbol; the helpers walk the tag chain at runtime.
inspectComposition(fn)— returns an array like['receipted','invariant','idempotent','saga'].getCompositionLabel(fn)— renders the chain with arrows.checkComposition(fn)— returnsCompositionWarning[]for inverted orders or duplicated wrappers; empty array on the recommended layout.
import { inspectComposition, getCompositionLabel, checkComposition } from '@openacid/acid'
const fn = receipted(o1)(invariant(o2)(idempotent(o3)(saga(o4))))
inspectComposition(fn) // ['receipted','invariant','idempotent','saga']
getCompositionLabel(fn) // 'receipted→invariant→idempotent→saga'
checkComposition(fn) // [] — no warnings; recommended orderAdapters
Concrete implementations of StorageAdapter, ChainAdapter, and SignerAdapter. Every storage adapter must pass the 12-case storageConformanceCases contract suite exported from core.
@openacid/adapter-memory
StorageAdapter with atomic compare-and-swap; MemorySigner with real secp256k1 signing. For tests and the dry-run demo path. Not for production.
npm i @openacid/adapter-memoryExports: MemoryStorageAdapter · MemorySigner
view on npm ↗ · workspace path packages/adapter-memory · latest v0.2.0 · history 0.1.0 → 0.1.1 → 0.1.2 → 0.2.0
@openacid/adapter-viem
ChainAdapter wrapping a viem PublicClient (getTxByHash, getTxByNonce, waitForFinality). ViemSigner for production wallets.
npm i @openacid/adapter-viemExports: ViemChainAdapter · ViemSigner
view on npm ↗ · workspace path packages/adapter-viem · latest v0.2.0 · history 0.1.0 → 0.1.1 → 0.1.2 → 0.2.0
@openacid/adapter-0g-storage
Write-through StorageAdapter on 0G blob storage. Receipts persist as content-addressed blobs; pointers live in-process for hot reads. Live conformance suite passes 10/10 against Galileo testnet.
npm i @openacid/adapter-0g-storageExports: ZeroGStorageAdapter
view on npm ↗ · workspace path packages/adapter-0g-storage · latest v0.2.0 · history 0.1.0 → 0.1.1 → 0.1.2 → 0.2.0
@openacid/adapter-ens
Mirrors receipt CIDs to ENS text records on every emitted receipt. Wires into receipted() via onReceipt. Any third party can resolve openacid.eth/receipt.latest with no library install.
npm i @openacid/adapter-ensExports: EnsReceiptMirror
view on npm ↗ · workspace path packages/adapter-ens · latest v0.2.0 · history 0.1.0 → 0.1.1 → 0.1.2 → 0.2.0
Recipes
Eight patterns drawn from the reference agent and the integration tests.
kill -9 recovery
Process dies after broadcasting an approve, before the swap. On restart you do not want to re-broadcast.
import { chainAwareBroadcast } from '@openacid/acid'
const out = await chainAwareBroadcast(
{
storage,
chain,
trackKey: `swap:${args.id}:tx`,
confirmations: 1,
},
async () => walletClient.writeContract({ ... }), // returns hash
)
if (out.reused) {
// last run already broadcast; we just waited for finality.
}Allowance hygiene
A "successful" saga can leak an allowance pointing at a router. A postcondition catches it.
import { invariant, noOrphanAllowances } from '@openacid/acid'
invariant({
post: noOrphanAllowances({
getAllowances: async () => [
{ token: USDC, spender: router, amount: await readAllowance(USDC, agent, router) },
],
}),
})(swapSaga)Isolation under retries
LLM planner emits the same tool call twice within a few hundred milliseconds. You want one swap, two identical results.
import { idempotent } from '@openacid/acid'
const dedup = idempotent({
key: (a) => `rebalance:${a.targetRatio}:${a.deadline}`,
storage,
inFlight: 'block',
ttl: 600,
})(rebalance)
// two parallel calls with the same args → one execution, both resolve to the same result
await Promise.all([dedup(args), dedup(args)])Verify a stored receipt
Recover the signer from a stored receipt. Works off-chain (in JS) and on-chain (via the registry contract).
import { verifyReceipt } from '@openacid/acid'
const ok = await verifyReceipt(
receipt,
agentAddress,
{ chainId: 16602 },
)
// On chain: ReceiptRegistry.verifyReceipt(anchorId, digest, proof, sig)
// → ecrecover(digest, v, r, s) === anchor.signerIntrospect composition
Confirm at runtime that your wrappers are nested in the recommended order. Useful for log decoration.
import { getCompositionLabel } from '@openacid/acid'
console.log('action:', getCompositionLabel(action))
// "receipted→invariant→idempotent→saga"Mirror to ENS
Publish receipt CIDs as ENS text records. Any third party can resolve openacid.eth/receipt.latest with no library install.
import { receipted } from '@openacid/acid'
import { EnsReceiptMirror } from '@openacid/adapter-ens'
const mirror = new EnsReceiptMirror({
walletClient, // viem wallet on Sepolia
resolver: '0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5',
subname: 'openacid.eth',
})
const action = receipted({ ..., onReceipt: mirror.onReceipt })(saga)Hard timeouts
Bound a hung call so the agent loop survives. withTimeout races the wrapped fn against a deadline and throws TimeoutError.
import { withTimeout, TimeoutError } from '@openacid/acid'
const safe = withTimeout({ ms: 30_000, label: 'rebalance' })(action)
try {
await safe(args)
} catch (err) {
if (err instanceof TimeoutError) {
// loop survives; re-queue or skip
}
throw err
}Recipient whitelist
Defense in depth: even if a buggy step or a poisoned LLM output produces a swap to an unknown address, the postcondition rejects it.
import { invariant, recipientWhitelist } from '@openacid/acid'
invariant({
post: recipientWhitelist({
allowed: [router, vault, agentSelf],
getRecipients: async (_a, r) => extractAddresses(r),
}),
})(action)Demo
Five scripted scenes ship with the reference agent. The first four run in-memory in seconds; the fifth runs one tick of the rebalancer against real Base Sepolia + 0G Galileo + Sepolia ENS, then polls the resolver until receipt.latest reflects the new callId.
./scripts/demo.shpnpm --filter @openacid/example-uniswap-agent demo:live| scene | command | what it proves | time |
|---|---|---|---|
| A | demo:a | saga + reverse compensations | ~2s |
| C | demo:c | noOrphanAllowances rejects a buggy "success" | ~1s |
| I | demo:i | two parallel calls, exactly one execution | ~1.5s |
| D | demo:d | kill-9 simulation; verifyReceipt recovers signer | ~1s |
| ★ | demo:live | real Base Sepolia + 0G + ENS readback | ~30-40s |
Live deployment
Every claim in this documentation has an on-chain receipt. Verify with the cast snippets below.
ReceiptRegistry on 0G Galileo
- Address:
0xd3E6277960025B4D0c161e20304a3a44231d0D1C - Chain ID: 16602
- RPC:
https://evmrpc-testnet.0g.ai - Explorer: contract page ↗ · deploy tx ↗
ENS parent name
- Name:
openacid.ethon Sepolia (chainId 11155111) - Resolver:
0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5 - Records: receipt.latest · receipt.head · agent.signer · description
- Owner:
0x3ca83AE589a1d23AccfD43667FeE65605AdBDC9A - Register tx: view ↗
Read the latest receipt CID
cast call 0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5 \
"text(bytes32,string)(string)" \
$(cast namehash openacid.eth) "receipt.latest" \
--rpc-url https://ethereum-sepolia-rpc.publicnode.comRead the agent's anchor count
cast call 0xd3E6277960025B4D0c161e20304a3a44231d0D1C \
"anchorCount(address)(uint64)" \
0x3ca83AE589a1d23AccfD43667FeE65605AdBDC9A \
--rpc-url https://evmrpc-testnet.0g.aiErrors
All errors extend AcidError (which extends Error). Saga and compensation errors carry an Error.cause chain pointing at the underlying throw, plus saga metadata { sagaId, attempt } on the instance.
| class | thrown by | fields |
|---|---|---|
| NonDeterministicKeyError | idempotent (strict mode) | samples |
| IdempotentInFlightError | idempotent (reject mode / block timeout) | key |
| IdempotentInFlightLostError | idempotent (in-flight marker disappeared) | key |
| InvariantViolationError | invariant | phase, reason, severity, context |
| SagaStepError | saga (step throws) | stepId, cause, sagaId, attempt |
| SagaCompensationError | saga (compensation throws) | stepId, cause, sagaId, attempt |
| ReceiptVerificationError | verifyReceipt (signature mismatch) | reason |
| TimeoutError | withTimeout | timeoutMs, label |
Limitations
Every guarantee has a scope. The library is honest about its boundaries.
| property | bound | notes |
|---|---|---|
| Atomicity | bounded by saga scope | forget to wrap → no atomicity for that step |
| Consistency | predicates are user-defined | GIGO — the library cannot infer your invariants |
| Isolation | action-level, not multi-action | cross-saga serializability needs an external lock manager (v1) |
| Durability | 1-block finality on L2s for v0 | reorg-aware durability is v1; pin a longer waitForFinality in production |
| LLM determinism | approximate replay | requires pinned model + seed; receipts are audit, not exact replay |
| Receipt signatures | tamper-evident | not tamper-proof — proves attestation, not truth |
| 0G Storage adapter | single-process atomic cas | multi-process atomicity needs an external lock service |
| ENS subname registrar | parent name only in v0 | per-agent subname registrar deferred to v1 |