openacid · v0.2.0

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.

local + tests
pnpm add @openacid/acid @openacid/adapter-memory
production wiring
pnpm add @openacid/adapter-viem @openacid/adapter-0g-storage @openacid/adapter-ens

Requirements

  • Node 20+. ESM + CJS dual builds; full .d.ts typings.
  • 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

optiontypenotes
stepsSagaStep<A>[]ordered list; each has id and async do(ctx)
compensationsRecord<stepId, CompensationFn>reverse-order undo on failure
storageStorageAdapterpersists saga state for crash recovery
onPartialFailure'compensate' | 'halt' | 'retry-forward'default: compensate
namespacestringstorage 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

optiontypenotes
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?) => voidconsumer 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

optiontypenotes
key(args) => stringdeterministic; rejected by strict-key check if it varies
storageStorageAdapterin-flight marker + result cache
inFlight'block' | 'return-pending' | 'reject'default: block
ttlnumber (seconds)completed-result cache TTL; default 3600
strictKeysbooleandefault true
pollIntervalMsnumber'block' mode poll cadence; default 50
blockTimeoutMsnumbergive up waiting after N ms; default 30000
namespacestringstorage 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

optiontypenotes
storageStorageAdapterwhere receipts persist (typically 0G Storage)
signerSignerAdaptersigns the EIP-712 typed-data digest
chain{ chainId; verifyingContract? }EIP-712 domain
fnNamestringhuman-readable identifier in the receipt
prevReceiptKeystringagent-scoped key for receipt chaining
collectTxRefs(result) => string[]extract on-chain tx hashes for the receipt
onReceipt(receipt) => Promise<void>callback fired after persistence
namespacestringstorage key prefix; default 'receipt'

Receipt shape

  • callId — content-addressed identifier (bytes32)
  • prevReceipt — chain pointer (bytes32 | null)
  • fnName, inputHash, outputHash
  • txRefs[] — on-chain tx hashes captured by collectTxRefs
  • startedAt, endedAt, retries
  • signature — 65-byte secp256k1 over the EIP-712 digest
  • cid — 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) — returns CompositionWarning[] 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 order

Adapters

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-memory

Exports: 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-viem

Exports: 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-storage

Exports: 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-ens

Exports: 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.signer

Introspect 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.

A/C/I/D in sequence (~6s)
./scripts/demo.sh
live tick (env required)
pnpm --filter @openacid/example-uniswap-agent demo:live
scenecommandwhat it provestime
Ademo:asaga + reverse compensations~2s
Cdemo:cnoOrphanAllowances rejects a buggy "success"~1s
Idemo:itwo parallel calls, exactly one execution~1.5s
Ddemo:dkill-9 simulation; verifyReceipt recovers signer~1s
demo:livereal 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

ENS parent name

  • Name: openacid.eth on 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.com

Read the agent's anchor count

cast call 0xd3E6277960025B4D0c161e20304a3a44231d0D1C \
  "anchorCount(address)(uint64)" \
  0x3ca83AE589a1d23AccfD43667FeE65605AdBDC9A \
  --rpc-url https://evmrpc-testnet.0g.ai

Errors

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.

classthrown byfields
NonDeterministicKeyErroridempotent (strict mode)samples
IdempotentInFlightErroridempotent (reject mode / block timeout)key
IdempotentInFlightLostErroridempotent (in-flight marker disappeared)key
InvariantViolationErrorinvariantphase, reason, severity, context
SagaStepErrorsaga (step throws)stepId, cause, sagaId, attempt
SagaCompensationErrorsaga (compensation throws)stepId, cause, sagaId, attempt
ReceiptVerificationErrorverifyReceipt (signature mismatch)reason
TimeoutErrorwithTimeouttimeoutMs, label

Limitations

Every guarantee has a scope. The library is honest about its boundaries.

propertyboundnotes
Atomicitybounded by saga scopeforget to wrap → no atomicity for that step
Consistencypredicates are user-definedGIGO — the library cannot infer your invariants
Isolationaction-level, not multi-actioncross-saga serializability needs an external lock manager (v1)
Durability1-block finality on L2s for v0reorg-aware durability is v1; pin a longer waitForFinality in production
LLM determinismapproximate replayrequires pinned model + seed; receipts are audit, not exact replay
Receipt signaturestamper-evidentnot tamper-proof — proves attestation, not truth
0G Storage adaptersingle-process atomic casmulti-process atomicity needs an external lock service
ENS subname registrarparent name only in v0per-agent subname registrar deferred to v1

Browse npm packages → · Back to home