Solana ProgramsAuditAnchor

AuditAnchor is the keystone of Regent’s tamper-evidence claim: every audit event ever ingested can be proven — or disproven — against an account in this program.

It holds two things. A chain: one account with a running head over every batch root in order, so the audit trail is provably complete and unaltered, not merely individually hashed. And epochs: periodic Merkle roots over ranges of that chain, which keep proofs short and give each range a Bitcoin timestamp. The original per-batch account (anchor_batch) still runs on devnet so that every account written since April 2026 and every published recipe keeps working.

Program ID (devnet)8N1PpbJZKmvJjG86XWpP82XrWzp8HY5FHZuzyQTgjJas
FrameworkAnchor 0.31.1
Upgradeable?On devnet, yes — the upgrade authority 47BBdKL1SwY7auTN4mQpnQAFX14bCjs7qie9R34FrXxc is held by Regent so fixes can ship during the pilot phase. ADR-010 commits the core programs to be non-upgradeable on mainnet
Sourceregent-protocol/regent-solana-programs → programs/audit-anchor

Why a separate program

Agents and mandates are bounded — there are some number of agents, some number of mandates, and each has a lifecycle. Audit events are unbounded: every action of every agent produces one. Batching them into Merkle roots is the only way to keep on-chain costs constant per event regardless of throughput.

Account layout

#[account]
pub struct Batch {
    pub batch_id: [u8; 32],     // the batch UUID's 16 bytes, left-padded with zeros
    pub merkle_root: [u8; 32],
    pub event_count: u32,
    pub anchored_at: i64,       // unix seconds, set by the program from the cluster clock
    pub bump: u8,
}

PDA seeds: [b"batch", batch_id].

The account is tiny — 85 bytes including the 8-byte discriminator — so anchoring is cheap even at scale.

Instructions

anchor_batch

Writes a new Batch account. Once written, an account is immutable — there’s no update_batch instruction by design. The program rejects an empty batch (event_count == 0) and an all-zero root.

ArgType
batch_id[u8; 32]
merkle_root[u8; 32]
event_countu32

Off-chain, api-audit accumulates events until either:

  • the batch reaches its size threshold (100 events), or
  • the auto-seal timer fires (seconds, configured per deployment)

…then computes the Merkle root over the events’ payload hashes (SHA-256, sha256(left ‖ right), an odd node paired with itself) and hands the sealed batch to blockchain-worker, which calls anchor_batch. The transaction signature is recorded on every event in the batch as solana_tx.

append_batch

Appends one batch root to the audit chain — the single account that makes the series of batches provable, not just each batch individually.

ArgTypeNotes
batch_root[u8; 32]the batch’s Merkle root
sequ64must equal chain.seq + 1
prev_head[u8; 32]must equal chain.head — the caller states which history it extends
#[account]
pub struct AuditChain {            // seeds = [b"chain"], created once
    pub head: [u8; 32],            // sha256(head_prev ‖ batch_root ‖ seq_le8); zeros before the first
    pub seq: u64,                  // latest appended batch; the program requires seq == chain.seq + 1
    pub last_appended_at: i64,
    pub epoch_count: u64,
    pub last_epoch_end_seq: u64,
    pub bump: u8,
}

The two guards are the point: a gap in seq is refused on chain, so a batch cannot be skipped, and prev_head means a client whose own record of the chain has drifted is refused rather than quietly extending a different history. Any change to an earlier batch changes every head after it. Cost is one transaction fee and no rent, which is why this replaces the per-batch account on mainnet — at one batch every 30 seconds the accounts cost roughly $379 a day in permanent rent, the chain about $1.90.

anchor_epoch

Records a Merkle root over the batch roots of a contiguous sequence range, so a single event is proven with two short Merkle paths instead of a replay of the chain.

ArgType
epoch_indexu64 (1-based; PDA seed, little-endian)
first_sequ64
last_sequ64
merkle_root[u8; 32]
#[account]
pub struct Epoch {                 // seeds = [b"epoch", epoch_index_le8]
    pub epoch_index: u64,
    pub first_seq: u64,            // must be last_epoch_end_seq + 1
    pub last_seq: u64,             // must be <= chain.seq
    pub merkle_root: [u8; 32],
    pub anchored_at: i64,
    pub bump: u8,
}

Epochs are sequential and contiguous by construction, so they tile the chain with no gaps and no overlaps. Each epoch root is also stamped into Bitcoin through OpenTimestamps; the proof is served at /v1/audit/epochs/{n}/ots.

init_chain

Creates the AuditChain account. Run once per deployment by the operator.

transfer_authority, init_admin, transfer_admin

config.authority is the operator — the hot key the anchoring service signs with. A separate AdminConfig PDA (seeds [b"admin"]) holds the admin, a multisig on mainnet, and only the admin can rotate the operator. init_admin is the one-time bootstrap; transfer_admin hands the admin role on. A compromised operator key can therefore write records — which the trail exposes — but cannot change who is in charge.

How proofs work

Once a batch is sealed, every event in it has a Merkle proof — a list of sibling hashes that lets anyone verify the event without downloading the rest of the batch.

If you want to prove that “event 1” was in this batch:

  1. Hash event 1’s canonical payload → leaf hash H
  2. Combine with sibling G (provided in the proof) → get parent D
  3. Combine D with sibling C (provided) → get parent A
  4. Combine A with sibling B (provided) → root R
  5. Compare R against the on-chain merkle_root

Match → event is provably in the batch. No match → either the event was forged, or the batch has been tampered with off-chain.

Why this matters

Without on-chain anchoring, an audit log can be rewritten silently. With anchoring:

  • Adding a fake event after the batch is sealed produces a different root → mismatch
  • Deleting a real event produces a different root → mismatch
  • Modifying any event’s payload produces a different root → mismatch

The only way to forge convincingly would be to issue a new on-chain transaction with the modified root — which leaves a fingerprint on Solana that anyone can see (and that would conflict with the original transaction).

Reading a batch off-chain

// batch_id on-chain = the UUID's 16 bytes, left-padded to 32
const batchIdBytes = Buffer.concat([Buffer.alloc(16), Buffer.from(batchId.replace(/-/g, ""), "hex")]);
const [batchPda] = PublicKey.findProgramAddressSync(
  [Buffer.from("batch"), batchIdBytes],
  programId,
);
const batch = await program.account.batch.fetch(batchPda);
console.log("Merkle root:", Buffer.from(batch.merkleRoot).toString("hex"));
console.log("Event count:", batch.eventCount);
console.log("Anchored at:", new Date(batch.anchoredAt * 1000));

To verify an individual event, see Verifying on-chain.